fix(tools): sandbox FileWriterTool writes and fix file tool rough edges (#6692)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Check Documentation Broken Links / Check broken links (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run

* fix(tools): sandbox FileWriterTool writes and fix file tool rough edges

FileReadTool confined reads to the working directory, but FileWriterTool
only checked that `filename` stayed inside `directory` — and `directory`
itself is an LLM-supplied schema field. An agent could therefore write
anywhere the process had permission to, including ~/.ssh and site-packages,
while the reader refused to read back what the writer had just written.
FileWriterTool was the only filesystem tool in the package that did not go
through validate_file_path; files_compressor_tool validates even its
output path.

Writes are now confined to base_dir (the working directory by default):
the resolved directory must sit inside base_dir, and the resolved file
must sit inside that directory. The pre-existing filename containment
check is kept as-is and still applies even when the unsafe-paths escape
hatch is on, so no existing guarantee is weakened.

Both tools gain a base_dir field so a developer can widen the sandbox
deliberately instead of reaching for the process-wide
CREWAI_TOOLS_ALLOW_UNSAFE_PATHS kill switch. FileReadTool also stops
rejecting a file_path given to its own constructor: that is
developer-declared intent, and declaring one file does not expose its
siblings.

Also fixed:

- FileReadTool scanned the whole file when reading a line window; it now
  stops via islice once the requested lines are collected.
- FileWriterTool._run(**kwargs) made the documented positional call
  signature raise TypeError and turned a missing overwrite into
  "error accessing key". It now takes named parameters in the documented
  (filename, content, directory) order.
- A directory naming an existing file reported "already exists and
  overwrite option was not passed" even with overwrite=True; it now
  explains the real problem.
- Subdirectories inside filename are created, matching what passing
  directory already did.
- Both tools now write and decode UTF-8 by default instead of the
  platform locale encoding, with an encoding field to override. The docs
  already claimed UTF-8 and recommended the writer to Windows users.
- The writer's schema fields had no descriptions for the LLM.
- Docs claimed FileReadTool parses JSON into a dict (it never has),
  shipped a snippet that raised TypeError, and did not mention the path
  sandbox. The writer README also began with a stray "Here's the
  rewritten README" preamble.

BREAKING CHANGE: FileWriterTool no longer writes outside the working
directory. Pass base_dir to authorize a different tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: update tool specifications

* fix(tools): make the declared FileReadTool file reachable by agents

Addresses review feedback on #6692.

The constructor-path exemption did not actually work the way an agent
calls the tool. The description only advertises a redacted label (the
basename, when the file sits outside the sandbox), but resolution
required the exact absolute path, so the model's call was sandboxed and
the declared file was never read. Worse, file_path was a required schema
field, so the long-documented "call with no arguments to read the default
file" raised a validation error instead:

    FileReadTool(file_path="/outside/declared.txt")
    .run()                          -> ValueError: validation failed
    .run(file_path="declared.txt")  -> Error: File not found
    .run(file_path="/outside/declared.txt") -> works, but the model was
                                               never told this path

file_path is now optional in the schema, so omitting it reads the default,
and the declared file is addressable by the label the description shows
the model as well as by its real path. Declaring one file still does not
expose its siblings.

The declared path is also pinned to its real path at construction, so a
later chdir cannot silently repoint it at a different file — previously a
relative constructor path re-resolved against the new working directory
on every call.

Also guards the writer's filepath resolution, which could raise
ValueError out of _run for a filename containing a null byte, breaking
the contract of always returning a descriptive string. The directory and
read paths were already guarded.

Adds docstrings to strtobool and both _run methods, corrects an Arabic
tanween spelling and a kaf-as-descriptor calque in the localized read
docs, and regenerates tool.specs.json for the schema change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): anchor the declared read path to base_dir, not the cwd

Addresses the second round of review feedback on #6692.

The previous commit pinned a relative constructor file_path with
os.path.realpath, which anchors to the working directory, while both
format_path_for_display and validate_file_path anchor a relative path to
base_dir. With the two roots disagreeing, the same relative string meant
two different files — and the tool served the cwd one under a label that
looks like it belongs to the sandbox:

    FileReadTool(file_path="data.txt", base_dir="/allowed")   # cwd=/work
    label advertised to the model -> "data.txt"
    run(file_path="data.txt")     -> contents of /work/data.txt

That reads a file from outside base_dir, so it was a sandbox escape
introduced by the exemption itself, not just a wrong-file bug.

Resolution now goes through a single _resolve_against_base helper that
anchors relative paths exactly the way the sandbox does, so the pinned
path, the advertised label and the containment check all agree. Covered
by test_relative_declared_path_anchors_to_base_dir.

Also softens "always readable" to "always allowed past the containment
check" in the docstring, README and docs, since bypassing containment
does not guarantee the read succeeds — it can still fail on a missing
file, a directory, or permissions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): tell the LLM about the path sandbox in tool descriptions

Addresses the low-confidence notes from the Copilot review on #6692.

Both tools' descriptions were pre-sandbox wording, so the model learned
about containment only by attempting a path and reading the error back.
Both now state that access is confined to the tool's allowed directory
and that a path resolving outside it is rejected.

The wording deliberately says "the tool's allowed directory" rather than
"the working directory", because the root is base_dir when one is set,
and naming the absolute root would leak it into the prompt — the same
reason paths are redacted in errors.

Not changed: the notes also suggested advertising `encoding`. That is a
constructor-only field the model cannot set, so describing it to the LLM
would be misleading.

Also fixes a test docstring that contradicted its own assertion — the
public run() path does raise on schema validation failure, which is what
the test asserts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): anchor base_dir at construction so the sandbox cannot move

Addresses the third review round on #6692.

Both remaining findings came from the same habit: storing an unanchored
string and re-resolving it later.

A relative base_dir was kept verbatim and re-resolved against getcwd() on
every call, while the declared file was pinned once at construction. After
a chdir the sandbox root moved but the declared default did not, so one
tool applied two different roots. base_dir is now resolved once — in the
reader's __init__, and via a field_validator on the writer so it also
applies on the model_validate path.

That also covers the serialization concern. model_dump drops the private
pin, and __init__ re-runs on restore, so a relative file_path was
re-anchored against whatever the working directory happened to be at load
time. With base_dir anchored, restore rebuilds the identical pin.

The residual case is a relative file_path with no base_dir, where the
sandbox root is the working directory too — so both move together and the
tool stays self-consistent. Covered by
test_declared_path_survives_a_serialization_round_trip and
test_relative_base_dir_is_anchored_at_construction on both tools.

Also corrects the writer's 'directory' description, README and docs: the
default resolves inside the tool's allowed directory, which is base_dir
when one is set, not always the working directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
João Moura
2026-07-28 06:20:26 -03:00
committed by GitHub
parent 97981ed31b
commit 2e95bfb4e8
17 changed files with 1017 additions and 111 deletions

View File

@@ -11,7 +11,7 @@ mode: "wide"
لا نزال نعمل على تحسين الأدوات، لذا قد يحدث سلوك غير متوقع أو تغييرات في المستقبل.
</Note>
تمثل أداة FileReadTool مفهومياً مجموعة من الوظائف ضمن حزمة crewai_tools تهدف إلى تسهيل قراءة الملفات واسترجاع المحتوى. تتضمن هذه المجموعة أدوات لمعالجة ملفات نصية دفعية، وقراءة ملفات التكوين أثناء التشغيل، واستيراد البيانات للتحليلات. تدعم مجموعة متنوعة من صيغ الملفات النصية مثل `.txt` و `.csv` و `.json` وغيرها. اعتماداً على نوع الملف، توفر المجموعة وظائف متخصصة، مثل تحويل محتوى JSON إلى قاموس Python لسهولة الاستخدام.
تمثل أداة FileReadTool مفهوميًا مجموعة من الوظائف ضمن حزمة crewai_tools تهدف إلى تسهيل قراءة الملفات واسترجاع المحتوى. تتضمن هذه المجموعة أدوات لمعالجة ملفات نصية دفعية، وقراءة ملفات التكوين أثناء التشغيل، واستيراد البيانات للتحليلات. تدعم مجموعة متنوعة من صيغ الملفات النصية مثل `.txt` و `.csv` و `.json` وغيرها. يُعاد المحتوى دائمًا نصًا عاديًا.
## التثبيت

View File

@@ -30,7 +30,11 @@ from crewai_tools import FileWriterTool
file_writer_tool = FileWriterTool()
# Write content to a file in a specified directory
result = file_writer_tool._run('example.txt', 'This is a test content.', 'test_directory')
result = file_writer_tool.run(
filename='example.txt',
content='This is a test content.',
directory='test_directory',
)
print(result)
```

View File

@@ -11,10 +11,12 @@ mode: "wide"
We are still working on improving tools, so there might be unexpected behavior or changes in the future.
</Note>
The FileReadTool conceptually represents a suite of functionalities within the crewai_tools package aimed at facilitating file reading and content retrieval.
This suite includes tools for processing batch text files, reading runtime configuration files, and importing data for analytics.
It supports a variety of text-based file formats such as `.txt`, `.csv`, `.json`, and more. Depending on the file type, the suite offers specialized functionality,
such as converting JSON content into a Python dictionary for ease of use.
The `FileReadTool` reads the contents of a file from the local file system and returns it as text.
It is useful for batch text file processing, reading runtime configuration files, and importing data for analytics.
It supports any text-based file format, such as `.txt`, `.csv`, `.json`, and `.md`.
Content is always returned as plain text — parsing it (for example, `json.loads` on a `.json` file) is up to the agent or your own code.
For large files, `start_line` and `line_count` read just a window of lines instead of loading the whole file.
## Installation
@@ -31,15 +33,48 @@ To get started with the FileReadTool:
```python Code
from crewai_tools import FileReadTool
# Initialize the tool to read any files the agents knows or lean the path for
# Initialize the tool to read any file the agent knows or learns the path for
file_read_tool = FileReadTool()
# OR
# Initialize the tool with a specific file path, so the agent can only read the content of the specified file
# Initialize with a specific file path, so the agent reads that file by default
file_read_tool = FileReadTool(file_path='path/to/your/file.txt')
# Read a window of lines (lines 100-149) instead of the whole file
partial_content = file_read_tool.run(
file_path='path/to/your/file.txt',
start_line=100,
line_count=50,
)
```
## Arguments
- `file_path`: The path to the file you want to read. It accepts both absolute and relative paths. Ensure the file exists and you have the necessary permissions to access it.
The agent supplies these at runtime:
- `file_path`: (Optional) The path to the file you want to read. Accepts absolute and relative paths. Ensure the file exists and you have the necessary permissions to access it. Omit it to read the default file configured at construction; if there is no default, the tool reports that no path was provided.
- `start_line`: (Optional) The line number to start reading from (1-indexed). Defaults to `1`.
- `line_count`: (Optional) The number of lines to read. If omitted, reads from `start_line` to the end of the file.
You set these when constructing the tool:
- `file_path`: (Optional) A default file to read when the agent calls the tool with no arguments.
- `base_dir`: (Optional) The directory that runtime paths must stay inside. Defaults to the current working directory.
- `encoding`: (Optional) Text encoding used to decode the file. Defaults to `utf-8`.
## Allowed paths
Because the file path is usually chosen by an LLM at runtime, reads are confined to a sandbox:
- Paths supplied at runtime must resolve inside `base_dir`, which defaults to the current working directory. `..` segments and symlinks are resolved before the check, so they cannot be used to escape.
- A `file_path` passed to the constructor is developer-declared intent, so it is always allowed past the containment check — even outside `base_dir`. The read itself can still fail if the file is missing, is a directory, or is not permitted. It is pinned when the tool is built, so a later change of working directory cannot repoint it, and the agent can address it either by omitting `file_path` or by using the name shown in the tool's description. Declaring one file does not expose its siblings.
To let an agent read a directory tree outside the working directory, point `base_dir` at it:
```python Code
# The agent may read anything under /data, and nothing outside it
file_read_tool = FileReadTool(base_dir='/data')
```
As a last resort, setting `CREWAI_TOOLS_ALLOW_UNSAFE_PATHS=true` disables path validation. This applies process-wide to every crewai-tools tool, including the SSRF protections on URL-fetching tools, so prefer `base_dir`.

View File

@@ -11,7 +11,7 @@ mode: "wide"
The `FileWriterTool` is a component of the crewai_tools package, designed to simplify the process of writing content to files with cross-platform compatibility (Windows, Linux, macOS).
It is particularly useful in scenarios such as generating reports, saving logs, creating configuration files, and more.
This tool handles path differences across operating systems, supports UTF-8 encoding, and automatically creates directories if they don't exist, making it easier to organize your output reliably across different platforms.
This tool handles path differences across operating systems, writes UTF-8 by default rather than the platform's locale encoding, and automatically creates directories if they don't exist, making it easier to organize your output reliably across different platforms.
## Installation
@@ -32,15 +32,45 @@ from crewai_tools import FileWriterTool
file_writer_tool = FileWriterTool()
# Write content to a file in a specified directory
result = file_writer_tool._run('example.txt', 'This is a test content.', 'test_directory')
result = file_writer_tool.run(
filename='example.txt',
content='This is a test content.',
directory='test_directory',
)
print(result)
```
## Arguments
- `filename`: The name of the file you want to create or overwrite.
- `content`: The content to write into the file.
- `directory` (optional): The path to the directory where the file will be created. Defaults to the current directory (`.`). If the directory does not exist, it will be created.
The agent supplies these at runtime:
- `filename`: The name of the file to write, relative to `directory`. May include subdirectories, which are created if they don't exist.
- `content`: The text content to write into the file.
- `directory` (optional): The path to the directory where the file will be created. A relative path resolves inside the tool's allowed directory — `base_dir` when set, the current working directory otherwise — and defaults to its root. If the directory does not exist, it will be created.
- `overwrite` (optional): Whether to replace the file when it already exists. Accepts `true`/`false` (also `yes`/`no`, `on`/`off`, `1`/`0`). Defaults to `false`, which reports an error instead of replacing existing content.
You set these when constructing the tool:
- `base_dir` (optional): The directory that writes must stay inside. Defaults to the current working directory.
- `encoding` (optional): Text encoding used to write the file. Defaults to `utf-8`.
## Allowed paths
Because both the directory and the filename are usually chosen by an LLM at runtime, writes are confined to a sandbox:
- The resolved `directory` must be inside `base_dir`, which defaults to the current working directory.
- The resolved file must then be inside that `directory`. `..` segments, absolute paths, and symlinks are resolved before both checks, so they cannot be used to escape.
To let an agent write outside the working directory, point `base_dir` at the target tree:
```python Code
# The agent may write anywhere under /var/output, and nowhere outside it
file_writer_tool = FileWriterTool(base_dir='/var/output')
```
<Note>
Previously an absolute `directory` could write anywhere the process had permission to. If you relied on that, set `base_dir` to the tree you want to allow. Setting `CREWAI_TOOLS_ALLOW_UNSAFE_PATHS=true` restores the old behavior, but it applies process-wide to every crewai-tools tool, including the SSRF protections on URL-fetching tools, so prefer `base_dir`.
</Note>
## Conclusion

View File

@@ -13,8 +13,7 @@ mode: "wide"
FileReadTool은 crewai_tools 패키지 내에서 파일 읽기와 콘텐츠 검색을 용이하게 하는 기능 모음입니다.
이 모음에는 배치 텍스트 파일 처리, 런타임 구성 파일 읽기, 분석을 위한 데이터 가져오기 등 다양한 도구가 포함되어 있습니다.
`.txt`, `.csv`, `.json` 등 다양한 텍스트 기반 파일 형식을 지원합니다. 파일 유형에 따라 이 모음은
JSON 콘텐츠를 Python 딕셔너리로 변환하여 사용을 쉽게 하는 등 특화된 기능을 제공합니다.
`.txt`, `.csv`, `.json` 등 다양한 텍스트 기반 파일 형식을 지원합니다. 콘텐츠는 항상 일반 텍스트로 반환됩니다.
## 설치

View File

@@ -32,7 +32,11 @@ from crewai_tools import FileWriterTool
file_writer_tool = FileWriterTool()
# Write content to a file in a specified directory
result = file_writer_tool._run('example.txt', 'This is a test content.', 'test_directory')
result = file_writer_tool.run(
filename='example.txt',
content='This is a test content.',
directory='test_directory',
)
print(result)
```

View File

@@ -13,8 +13,7 @@ mode: "wide"
O FileReadTool representa conceitualmente um conjunto de funcionalidades dentro do pacote crewai_tools voltadas para facilitar a leitura e a recuperação de conteúdo de arquivos.
Esse conjunto inclui ferramentas para processar arquivos de texto em lote, ler arquivos de configuração em tempo de execução e importar dados para análise.
Ele suporta uma variedade de formatos de arquivo baseados em texto, como `.txt`, `.csv`, `.json` e outros. Dependendo do tipo de arquivo, o conjunto oferece funcionalidades especializadas,
como converter conteúdo JSON em um dicionário Python para facilitar o uso.
Ele suporta uma variedade de formatos de arquivo baseados em texto, como `.txt`, `.csv`, `.json` e outros. O conteúdo é sempre retornado como texto simples.
## Instalação

View File

@@ -32,7 +32,11 @@ from crewai_tools import FileWriterTool
file_writer_tool = FileWriterTool()
# Escreva conteúdo em um arquivo em um diretório especificado
result = file_writer_tool._run('example.txt', 'This is a test content.', 'test_directory')
result = file_writer_tool.run(
filename='example.txt',
content='This is a test content.',
directory='test_directory',
)
print(result)
```

View File

@@ -20,6 +20,7 @@ from urllib.parse import urlparse
logger = logging.getLogger(__name__)
_UNSAFE_PATHS_ENV = "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS"
_BYPASS_HINT = f"Set {_UNSAFE_PATHS_ENV}=true to bypass this check."
def format_path_for_display(path: str, base_dir: str | None = None) -> str:
@@ -47,6 +48,27 @@ def format_error_for_display(error: Exception) -> str:
return str(error)
def format_sandbox_error(error: Exception, remedy: str) -> str:
"""Restate a containment rejection with a tool-specific remedy.
Rejections from :func:`validate_file_path` end by advertising the
process-wide escape hatch, which also disables the SSRF checks on
URL-fetching tools. Tools that accept a narrower ``base_dir`` should point
at that instead, so callers reach for the blunt instrument last.
Args:
error: The rejection raised by path validation.
remedy: Guidance to offer in place of the escape-hatch advice.
Returns:
The rejection text with *remedy* substituted for the bypass advice.
"""
text = str(error)
if text.endswith(_BYPASS_HINT):
text = text[: -len(_BYPASS_HINT)].rstrip()
return f"{text} {remedy}".strip()
def _is_escape_hatch_enabled() -> bool:
"""Check if the unsafe paths escape hatch is enabled."""
return os.environ.get(_UNSAFE_PATHS_ENV, "").lower() in ("true", "1", "yes")

View File

@@ -2,9 +2,9 @@
## Description
The FileReadTool is a versatile component of the crewai_tools package, designed to streamline the process of reading and retrieving content from files. It is particularly useful in scenarios such as batch text file processing, runtime configuration file reading, and data importation for analytics. This tool supports various text-based file formats including `.txt`, `.csv`, `.json`, and adapts its functionality based on the file type, for instance, converting JSON content into a Python dictionary for easy use.
The FileReadTool is a versatile component of the crewai_tools package, designed to streamline the process of reading and retrieving content from files. It is particularly useful in scenarios such as batch text file processing, runtime configuration file reading, and data importation for analytics. This tool supports any text-based file format, including `.txt`, `.csv`, `.json`, and `.md`. Content is always returned as plain text — parsing it (for example, `json.loads` on a `.json` file) is up to the agent or your own code.
The tool also supports reading specific chunks of a file by specifying a starting line and the number of lines to read, which is helpful when working with large files that don't need to be loaded entirely into memory.
The tool also supports reading specific chunks of a file by specifying a starting line and the number of lines to read, which is helpful when working with large files that don't need to be loaded entirely into memory. Reading a window stops as soon as the requested lines have been collected, so it does not scan the rest of the file.
## Installation
@@ -21,12 +21,12 @@ To get started with the FileReadTool:
```python
from crewai_tools import FileReadTool
# Initialize the tool to read any files the agents knows or lean the path for
# Initialize the tool to read any file the agent knows or learns the path for
file_read_tool = FileReadTool()
# OR
# Initialize the tool with a specific file path, so the agent can only read the content of the specified file
# Initialize the tool with a specific file path, so the agent reads that file by default
file_read_tool = FileReadTool(file_path='path/to/your/file.txt')
# Read a specific chunk of the file (lines 100-149)
@@ -35,6 +35,30 @@ partial_content = file_read_tool.run(file_path='path/to/your/file.txt', start_li
## Arguments
- `file_path`: The path to the file you want to read. It accepts both absolute and relative paths. Ensure the file exists and you have the necessary permissions to access it.
The agent supplies these at runtime:
- `file_path`: (Optional) The path to the file you want to read. It accepts both absolute and relative paths. Ensure the file exists and you have the necessary permissions to access it. Omit it to read the default file configured at construction; if there is no default, the tool reports that no path was provided.
- `start_line`: (Optional) The line number to start reading from (1-indexed). Defaults to 1 (the first line).
- `line_count`: (Optional) The number of lines to read. If not provided, reads from the start_line to the end of the file.
You set these when constructing the tool:
- `file_path`: (Optional) A default file to read when the agent calls the tool with no arguments.
- `base_dir`: (Optional) The directory that runtime paths must stay inside. Defaults to the current working directory.
- `encoding`: (Optional) Text encoding used to decode the file. Defaults to `utf-8`.
## Allowed paths
Because the file path is usually chosen by an LLM at runtime, reads are confined to a sandbox:
- Paths supplied at runtime must resolve inside `base_dir` (the current working directory by default). `..` segments and symlinks are resolved before the check, so they cannot be used to escape.
- A `file_path` passed to the constructor is developer-declared intent, so it is always allowed past the containment check — even outside `base_dir`. The read itself can still fail if the file is missing, is a directory, or is not permitted. It is pinned when the tool is built, so a later change of working directory cannot repoint it, and the agent can address it either by omitting `file_path` or by using the name shown in the tool's description. Declaring one file does not expose its siblings.
To let an agent read a directory tree outside the working directory, point `base_dir` at it:
```python
# The agent may read anything under /data, and nothing outside it
file_read_tool = FileReadTool(base_dir='/data')
```
Setting `CREWAI_TOOLS_ALLOW_UNSAFE_PATHS=true` disables path validation, but it applies process-wide to every crewai-tools tool, including the SSRF protections on URL-fetching tools, so prefer `base_dir`.

View File

@@ -1,19 +1,48 @@
from itertools import islice
import os
from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, PrivateAttr
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
format_sandbox_error,
validate_file_path,
)
def _resolve_against_base(path: str, base_dir: str | None) -> str:
"""Resolve *path* the way the sandbox does, anchoring relatives to *base_dir*.
``validate_file_path`` and ``format_path_for_display`` both join a relative
path onto *base_dir* rather than the working directory. Resolution has to
agree with them, or the same relative string would mean two different files.
Args:
path: The path to resolve.
base_dir: The anchor for relative paths. Defaults to the working directory.
Returns:
The resolved absolute path.
"""
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))
class FileReadToolSchema(BaseModel):
"""Input for FileReadTool."""
file_path: str = Field(..., description="Mandatory file full path to read the file")
file_path: str | None = Field(
None,
description=(
"Full path of the file to read. Omit it to read the tool's default "
"file, which only works when one was configured."
),
)
start_line: int | None = Field(
1, description="Line number to start reading from (1-indexed)"
)
@@ -26,17 +55,29 @@ class FileReadTool(BaseTool):
"""A tool for reading file contents.
This tool inherits its schema handling from BaseTool to avoid recursive schema
definition issues. The args_schema is set to FileReadToolSchema which defines
the required file_path parameter. The schema should not be overridden in the
constructor as it would break the inheritance chain and cause infinite loops.
definition issues. The args_schema is set to FileReadToolSchema, whose
file_path parameter is optional so the tool's default file can be read by
omitting it. The schema should not be overridden in the constructor as it
would break the inheritance chain and cause infinite loops.
The tool supports two ways of specifying the file path:
1. At construction time via the file_path parameter
2. At runtime via the file_path parameter in the tool's input
Paths supplied at runtime must resolve inside ``base_dir`` (the current
working directory by default), since they are typically chosen by an LLM.
A ``file_path`` given at construction time is developer-declared intent and
is always allowed past the containment check, even when it lives outside
``base_dir`` (the read itself can still fail). It is pinned at
construction, so a later chdir cannot repoint it, and it can be addressed
either by omitting ``file_path`` or by the label shown in the description.
Args:
file_path (Optional[str]): Path to the file to be read. If provided,
this becomes the default file path for the tool.
base_dir (Optional[str]): Directory that runtime paths must stay inside.
Defaults to the current working directory.
encoding (str): Text encoding used to decode the file. Defaults to UTF-8.
**kwargs: Additional keyword arguments passed to BaseTool.
Example:
@@ -46,29 +87,89 @@ class FileReadTool(BaseTool):
>>> content = tool.run(
... file_path="/path/to/file.txt", start_line=100, line_count=50
... ) # Reads lines 100-149
>>> # Widen the sandbox so the agent may read anything under /data:
>>> tool = FileReadTool(base_dir="/data")
"""
name: str = "Read a file's content"
description: str = "A tool that reads the content of a file. To use this tool, provide a 'file_path' parameter with the path to the file you want to read. Optionally, provide 'start_line' to start reading from a specific line and 'line_count' to limit the number of lines read."
description: str = "A tool that reads the content of a file. To use this tool, provide a 'file_path' parameter with the path to the file you want to read. Reads are confined to the tool's allowed directory; a path that resolves outside it is rejected. Optionally, provide 'start_line' to start reading from a specific line and 'line_count' to limit the number of lines read."
args_schema: type[BaseModel] = FileReadToolSchema
file_path: str | None = None
base_dir: str | None = None
encoding: str = "utf-8"
def __init__(self, file_path: str | None = None, **kwargs: Any) -> None:
# Pinned at construction so a later chdir cannot change which file the
# developer-declared default refers to.
_declared_realpath: str | None = PrivateAttr(default=None)
# The label the tool's description shows the LLM for the declared file.
_declared_label: str | None = PrivateAttr(default=None)
def __init__(
self,
file_path: str | None = None,
base_dir: str | None = None,
encoding: str = "utf-8",
**kwargs: Any,
) -> None:
"""Initialize the FileReadTool.
Args:
file_path (Optional[str]): Path to the file to be read. If provided,
this becomes the default file path for the tool.
base_dir (Optional[str]): Directory that runtime paths must stay
inside. Defaults to the current working directory.
encoding (str): Text encoding used to decode the file.
**kwargs: Additional keyword arguments passed to BaseTool.
"""
# 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)
display_path = None
if file_path is not None:
display_path = format_path_for_display(file_path)
display_path = format_path_for_display(file_path, base_dir)
kwargs["description"] = (
f"A tool that reads file content. The default file is {display_path}, but you can provide a different 'file_path' parameter to read another file. You can also specify 'start_line' and 'line_count' to read specific parts of the file."
f"A tool that reads file content. The default file is {display_path}, which is read when 'file_path' is omitted. You can also provide a different 'file_path' parameter to read another file, though reads are confined to the tool's allowed directory and a path that resolves outside it is rejected. Specify 'start_line' and 'line_count' to read specific parts of the file."
)
super().__init__(**kwargs)
self.file_path = file_path
self.base_dir = base_dir
self.encoding = encoding
self._declared_realpath = (
_resolve_against_base(file_path, base_dir)
if file_path is not None
else None
)
self._declared_label = display_path
def _resolve_path(self, file_path: str) -> str:
"""Resolve *file_path* and confirm the tool is allowed to read it.
The file declared at construction time is always allowed: the developer
named it, and the tool reads it anyway when ``file_path`` is omitted. It
is addressable both by its real path and by the label the description
shows the LLM, since that label is all the model is given. Everything
else — including any path an LLM invents at runtime — must resolve
inside ``base_dir``.
Args:
file_path: The path to resolve.
Returns:
The resolved, allowed absolute path.
Raises:
ValueError: If the path resolves outside ``base_dir``.
"""
declared = self._declared_realpath
if declared is not None and (
file_path == self._declared_label
or _resolve_against_base(file_path, self.base_dir) == declared
):
return declared
return validate_file_path(file_path, self.base_dir)
def _run(
self,
@@ -76,32 +177,36 @@ class FileReadTool(BaseTool):
start_line: int | None = 1,
line_count: int | None = None,
) -> str:
file_path = file_path or self.file_path
"""Read a file, or a window of its lines, as text."""
start_line = start_line or 1
line_count = line_count or None
if file_path is None:
return "Error: No file path provided. Please provide a file path either in the constructor or as an argument."
if self._declared_realpath is None:
return "Error: No file path provided. Please provide a file path either in the constructor or as an argument."
file_path = self._declared_realpath
else:
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.",
)
display_path = format_path_for_display(file_path, self.base_dir)
try:
file_path = validate_file_path(file_path)
except ValueError as e:
return f"Error: Invalid file path: {e!s}"
display_path = format_path_for_display(file_path)
try:
with open(file_path, "r") as file:
with open(file_path, "r", encoding=self.encoding) as file:
if start_line == 1 and line_count is None:
return file.read()
start_idx = max(start_line - 1, 0)
stop_idx = None if line_count is None else start_idx + line_count
selected_lines = [
line
for i, line in enumerate(file)
if i >= start_idx
and (line_count is None or i < start_idx + line_count)
]
# islice stops pulling lines once stop_idx is reached, so a small
# window near the top of a huge file does not scan the whole file.
selected_lines = list(islice(file, start_idx, stop_idx))
if not selected_lines and start_idx > 0:
return f"Error: Start line {start_line} exceeds the number of lines in the file."
@@ -111,6 +216,12 @@ class FileReadTool(BaseTool):
return f"Error: File not found at path: {display_path}"
except PermissionError:
return f"Error: Permission denied when trying to read file: {display_path}"
except UnicodeDecodeError:
return (
f"Error: Failed to decode file {display_path} as {self.encoding}. "
f"Pass a different 'encoding' to FileReadTool if the file uses "
f"another text encoding."
)
except Exception as e:
return (
f"Error: Failed to read file {display_path}. "

View File

@@ -1,9 +1,7 @@
Here's the rewritten README for the `FileWriterTool`:
# FileWriterTool Documentation
## Description
The `FileWriterTool` is a component of the crewai_tools package, designed to simplify the process of writing content to files. It is particularly useful in scenarios such as generating reports, saving logs, creating configuration files, and more. This tool supports creating new directories if they don't exist, making it easier to organize your output.
The `FileWriterTool` is a component of the crewai_tools package, designed to simplify the process of writing content to files. It is particularly useful in scenarios such as generating reports, saving logs, creating configuration files, and more. This tool supports creating new directories if they don't exist, making it easier to organize your output, and writes UTF-8 by default rather than the platform's locale encoding.
## Installation
Install the crewai_tools package to use the `FileWriterTool` in your projects:
@@ -22,14 +20,38 @@ from crewai_tools import FileWriterTool
file_writer_tool = FileWriterTool()
# Write content to a file in a specified directory
result = file_writer_tool._run('example.txt', 'This is a test content.', 'test_directory')
result = file_writer_tool.run(
filename='example.txt',
content='This is a test content.',
directory='test_directory',
)
print(result)
```
## Arguments
- `filename`: The name of the file you want to create or overwrite.
- `content`: The content to write into the file.
- `directory` (optional): The path to the directory where the file will be created. Defaults to the current directory (`.`). If the directory does not exist, it will be created.
The agent supplies these at runtime:
- `filename`: The name of the file to write, relative to `directory`. May include subdirectories, which are created if they don't exist.
- `content`: The text content to write into the file.
- `directory` (optional): The path to the directory where the file will be created. A relative path resolves inside the tool's allowed directory — `base_dir` when set, the current working directory otherwise — and defaults to its root. If the directory does not exist, it will be created.
- `overwrite` (optional): Whether to replace the file when it already exists. Accepts `true`/`false` (also `yes`/`no`, `on`/`off`, `1`/`0`). Defaults to `false`, which reports an error instead of replacing existing content.
You set these when constructing the tool:
- `base_dir` (optional): The directory that writes must stay inside. Defaults to the current working directory.
- `encoding` (optional): Text encoding used to write the file. Defaults to `utf-8`.
## Allowed paths
Because both the directory and the filename are usually chosen by an LLM at runtime, writes are confined to a sandbox: the resolved `directory` must be inside `base_dir` (the current working directory by default), and the resolved file must be inside that `directory`. `..` segments, absolute paths, and symlinks are resolved before both checks, so they cannot be used to escape.
To let an agent write outside the working directory, point `base_dir` at the target tree:
```python
# The agent may write anywhere under /var/output, and nowhere outside it
file_writer_tool = FileWriterTool(base_dir='/var/output')
```
Setting `CREWAI_TOOLS_ALLOW_UNSAFE_PATHS=true` disables path validation, but it applies process-wide to every crewai-tools tool, including the SSRF protections on URL-fetching tools, so prefer `base_dir`.
## Conclusion
By integrating the `FileWriterTool` into your crews, the agents can execute the process of writing content to files and creating directories. This tool is essential for tasks that require saving output data, creating structured file systems, and more. By adhering to the setup and usage guidelines provided, incorporating this tool into projects is straightforward and efficient.

View File

@@ -1,17 +1,29 @@
import os
from pathlib import Path
from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
format_sandbox_error,
validate_file_path,
)
def strtobool(val: str | bool) -> bool:
"""Coerce the spellings of true/false an LLM is likely to emit into a bool.
Args:
val: A bool, or one of y/yes/t/true/on/1 and n/no/f/false/off/0.
Returns:
The corresponding boolean.
Raises:
ValueError: If the string is not a recognized boolean spelling.
"""
if isinstance(val, bool):
return val
val = val.lower()
@@ -23,59 +35,140 @@ def strtobool(val: str | bool) -> bool:
class FileWriterToolInput(BaseModel):
filename: str
directory: str | None = "./"
overwrite: str | bool = False
content: str
"""Input for FileWriterTool."""
filename: str = Field(
...,
description=(
"Name of the file to write, relative to 'directory'. May include "
"subdirectories, which are created if they do not exist."
),
)
content: str = Field(..., description="The text content to write to the file.")
directory: str | None = Field(
"./",
description=(
"Directory to write the file into. A relative path resolves inside "
"the tool's allowed directory, and defaults to its root. Created if "
"it does not exist."
),
)
overwrite: str | bool = Field(
False,
description=(
"Whether to replace the file when it already exists. Accepts "
"true/false (also yes/no, on/off, 1/0). Defaults to false, which "
"reports an error instead of replacing existing content."
),
)
class FileWriterTool(BaseTool):
"""A tool for writing text content to a file.
Writes are confined to ``base_dir`` (the current working directory by
default), because the target directory and filename are typically chosen by
an LLM at runtime. Set ``base_dir`` to widen that sandbox deliberately.
Args:
base_dir (Optional[str]): Directory that writes must stay inside.
Defaults to the current working directory.
encoding (str): Text encoding used to write the file. Defaults to UTF-8.
Example:
>>> tool = FileWriterTool()
>>> tool.run(filename="report.md", content="# Report", overwrite=True)
>>> # Allow the agent to write anywhere under /var/output:
>>> tool = FileWriterTool(base_dir="/var/output")
"""
name: str = "File Writer Tool"
description: str = "A tool to write content to a specified file. Accepts filename, content, and optionally a directory path and overwrite flag as input."
description: str = "A tool to write content to a specified file. Accepts filename, content, and optionally a directory path and overwrite flag as input. Writes are confined to the tool's allowed directory; a filename or directory that resolves outside it is rejected."
args_schema: type[BaseModel] = FileWriterToolInput
base_dir: str | None = None
encoding: str = "utf-8"
@field_validator("base_dir")
@classmethod
def _anchor_base_dir(cls, value: str | None) -> str | None:
"""Resolve base_dir once so a later chdir cannot move the sandbox."""
return os.path.realpath(value) if value is not None else None
def _run(
self,
filename: str,
content: str,
directory: str | None = "./",
overwrite: str | bool = False,
) -> str:
"""Write *content* to *filename*, confined to the tool's sandbox."""
directory = directory or "./"
def _run(self, **kwargs: Any) -> str:
try:
directory = kwargs.get("directory") or "./"
filename = kwargs["filename"]
overwrite_file = strtobool(overwrite)
except ValueError as e:
return f"An error occurred while writing to the file: {e!s}"
filepath = os.path.join(directory, filename)
# Prevent path traversal: the resolved path must be strictly inside
# filename, and symlink escapes regardless of how directory is set.
# is_relative_to() does a proper path-component comparison that is
# safe on case-insensitive filesystems and avoids the "// " edge case
# We also reject the case where filepath resolves to the directory
# itself, since that is not a valid file target.
real_directory = Path(directory).resolve()
real_filepath = Path(filepath).resolve()
display_filepath = format_path_for_display(
str(real_filepath), str(real_directory)
# 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.
try:
resolved_directory = Path(validate_file_path(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.",
)
if (
not real_filepath.is_relative_to(real_directory)
or real_filepath == real_directory
):
return "Error: Invalid file path — the filename must not escape the target directory."
if kwargs.get("directory"):
os.makedirs(real_directory, exist_ok=True)
# 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.
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)}"
kwargs["overwrite"] = strtobool(kwargs["overwrite"])
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."
if os.path.exists(real_filepath) and not kwargs["overwrite"]:
return f"File {display_filepath} already exists and overwrite option was not passed."
# Covers both a missing 'directory' and subdirectories inside 'filename'.
try:
os.makedirs(resolved_filepath.parent, exist_ok=True)
except FileExistsError:
return (
f"Error: Cannot write to {display_filepath} because a file already "
f"exists where a directory is needed."
)
except OSError as e:
return (
f"Error: Could not create the directory for {display_filepath}. "
f"{format_error_for_display(e)}"
)
mode = "w" if kwargs["overwrite"] else "x"
with open(real_filepath, mode) as file:
file.write(kwargs["content"])
return f"Content successfully written to {display_filepath}"
if resolved_filepath.exists() and not overwrite_file:
return f"File {display_filepath} already exists and overwrite option was not passed."
mode = "w" if overwrite_file else "x"
try:
with open(resolved_filepath, mode, encoding=self.encoding) as file:
file.write(content)
except FileExistsError:
return f"File {display_filepath} already exists and overwrite option was not passed."
except KeyError as e:
return f"An error occurred while accessing key: {e!s}"
except Exception as e:
return (
"An error occurred while writing to the file: "
f"{format_error_for_display(e)}"
)
return f"Content successfully written to {display_filepath}"

View File

@@ -3,6 +3,34 @@ from unittest.mock import mock_open, patch
from crewai_tools import FileReadTool
class CountingFile:
"""Text-file stand-in that records how many lines were actually consumed."""
def __init__(self, lines):
self._lines = lines
self.consumed = 0
def __enter__(self):
return self
def __exit__(self, *exc_info):
return False
def __iter__(self):
return self
def __next__(self):
if self.consumed >= len(self._lines):
raise StopIteration
line = self._lines[self.consumed]
self.consumed += 1
return line
def read(self):
self.consumed = len(self._lines)
return "".join(self._lines)
def test_file_read_tool_constructor():
"""Test FileReadTool initialization with file_path."""
test_file = "test_file.txt"
@@ -186,3 +214,234 @@ def test_file_read_tool_invalid_path_error_does_not_disclose_workspace(
assert "outside.txt" in result
assert str(tmp_path) not in result
assert str(tmp_path.parent) not in result
# Point users at base_dir, not the process-wide escape hatch.
assert "base_dir" in result
assert "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS" not in result
def test_constructor_path_outside_working_directory_is_readable(tmp_path, monkeypatch):
"""A developer-declared file_path is trusted even outside the sandbox."""
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.chdir(workspace)
target = tmp_path / "declared.txt"
target.write_text("declared content")
tool = FileReadTool(file_path=str(target))
assert tool._run() == "declared content"
assert tool._run(file_path=str(target)) == "declared content"
def test_declared_file_is_reachable_the_way_an_agent_calls_it(tmp_path, monkeypatch):
"""The label in the description must resolve to the declared file.
The description only shows a redacted label, so that label is all the model
has to work with. It has to address the declared file.
"""
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.chdir(workspace)
target = tmp_path / "declared.txt"
target.write_text("declared content")
tool = FileReadTool(file_path=str(target))
label = tool._declared_label
assert label == "declared.txt"
assert label in tool.description
# Omitting file_path entirely, and passing the advertised label, both work.
assert tool.run() == "declared content"
assert tool.run(file_path=label) == "declared content"
def test_run_without_file_path_reports_error_when_no_default(tmp_path, monkeypatch):
"""file_path is optional in the schema, so this must not raise."""
monkeypatch.chdir(tmp_path)
assert "Error: No file path provided" in FileReadTool().run()
def test_declared_relative_path_survives_chdir(tmp_path, monkeypatch):
"""The declared file is pinned at construction, not re-resolved per call."""
monkeypatch.chdir(tmp_path)
(tmp_path / "rel.txt").write_text("original")
nested = tmp_path / "sub"
nested.mkdir()
(nested / "rel.txt").write_text("a different file")
tool = FileReadTool(file_path="rel.txt")
assert tool._run() == "original"
monkeypatch.chdir(nested)
assert tool._run() == "original"
assert tool._run(file_path="rel.txt") == "original"
def test_relative_declared_path_anchors_to_base_dir(tmp_path, monkeypatch):
"""A relative declared path must resolve against base_dir, not the cwd.
The advertised label is built against base_dir, so pinning against the cwd
would make the same name mean two different files — and would serve a file
from outside base_dir under a label that looks like it is inside.
"""
allowed = tmp_path / "allowed"
allowed.mkdir()
work = tmp_path / "work"
work.mkdir()
(allowed / "data.txt").write_text("sandbox file")
(work / "data.txt").write_text("cwd file")
monkeypatch.chdir(work)
tool = FileReadTool(file_path="data.txt", base_dir=str(allowed))
assert tool._declared_label == "data.txt"
assert tool._declared_realpath == str(allowed / "data.txt")
assert tool.run() == "sandbox file"
assert tool.run(file_path="data.txt") == "sandbox file"
def test_relative_base_dir_is_anchored_at_construction(tmp_path, monkeypatch):
"""A relative base_dir must not follow a later chdir.
Otherwise the sandbox root moves while the declared file stays pinned, and
the tool applies two different roots.
"""
monkeypatch.chdir(tmp_path)
allowed = tmp_path / "allowed"
allowed.mkdir()
(allowed / "data.txt").write_text("sandbox file")
nested = tmp_path / "sub"
nested.mkdir()
tool = FileReadTool(base_dir="allowed")
assert tool.base_dir == str(allowed)
monkeypatch.chdir(nested)
assert tool._run(file_path="data.txt") == "sandbox file"
def test_declared_path_survives_a_serialization_round_trip(tmp_path, monkeypatch):
"""model_dump drops private attrs, so the pin must be rebuilt on restore."""
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.chdir(workspace)
allowed = tmp_path / "allowed"
allowed.mkdir()
(allowed / "data.txt").write_text("sandbox file")
tool = FileReadTool(file_path="data.txt", base_dir=str(allowed))
restored = FileReadTool.model_validate(tool.model_dump())
assert restored._declared_realpath == tool._declared_realpath
assert restored.run() == "sandbox file"
# A chdir between dump and restore must not repoint the declared file.
nested = workspace / "sub"
nested.mkdir()
monkeypatch.chdir(nested)
assert FileReadTool.model_validate(tool.model_dump()).run() == "sandbox file"
def test_constructor_path_does_not_widen_the_sandbox(tmp_path, monkeypatch):
"""Declaring one file must not expose its siblings to the LLM."""
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.chdir(workspace)
declared = tmp_path / "declared.txt"
declared.write_text("declared content")
sibling = tmp_path / "sibling.txt"
sibling.write_text("secret")
result = FileReadTool(file_path=str(declared))._run(file_path=str(sibling))
assert "Invalid file path" in result
assert "secret" not in result
def test_base_dir_widens_the_sandbox(tmp_path, monkeypatch):
"""base_dir lets a developer authorize reads outside the working directory."""
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.chdir(workspace)
data = tmp_path / "data"
data.mkdir()
(data / "report.csv").write_text("a,b,c")
tool = FileReadTool(base_dir=str(data))
assert tool._run(file_path=str(data / "report.csv")) == "a,b,c"
assert tool._run(file_path="report.csv") == "a,b,c"
def test_base_dir_still_blocks_escapes(tmp_path, monkeypatch):
"""base_dir moves the sandbox; it does not remove it."""
monkeypatch.chdir(tmp_path)
data = tmp_path / "data"
data.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("secret")
result = FileReadTool(base_dir=str(data))._run(file_path=str(outside))
assert "Invalid file path" in result
assert "secret" not in result
def test_line_window_stops_reading_early():
"""A small window must not scan the rest of the file."""
handle = CountingFile([f"Line {i}\n" for i in range(1, 1001)])
with patch("builtins.open", return_value=handle):
result = FileReadTool()._run(
file_path="big.log", start_line=1, line_count=3
)
assert result == "Line 1\nLine 2\nLine 3\n"
assert handle.consumed == 3
def test_line_window_stops_early_with_offset():
handle = CountingFile([f"Line {i}\n" for i in range(1, 1001)])
with patch("builtins.open", return_value=handle):
result = FileReadTool()._run(
file_path="big.log", start_line=10, line_count=2
)
assert result == "Line 10\nLine 11\n"
assert handle.consumed == 11
def test_reads_utf8_by_default(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
content = "café — 日本語 — 🚀"
(tmp_path / "unicode.txt").write_bytes(content.encode("utf-8"))
assert FileReadTool()._run(file_path="unicode.txt") == content
def test_encoding_is_configurable(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "latin.txt").write_bytes("café".encode("latin-1"))
assert FileReadTool(encoding="latin-1")._run(file_path="latin.txt") == "café"
def test_null_byte_path_returns_error_instead_of_raising(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = FileReadTool()._run(file_path="a\x00b.txt")
assert "Error" in result
def test_decode_error_names_the_encoding(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "binary.bin").write_bytes(bytes(range(256)))
result = FileReadTool()._run(file_path="binary.bin")
assert "Failed to decode" in result
assert "utf-8" in result
assert str(tmp_path) not in result

View File

@@ -12,8 +12,10 @@ def tool():
@pytest.fixture
def temp_env():
temp_dir = tempfile.mkdtemp()
def temp_env(monkeypatch):
# Writes are confined to the working directory, so make the temp dir the cwd.
temp_dir = os.path.realpath(tempfile.mkdtemp())
monkeypatch.chdir(temp_dir)
test_file = "test.txt"
test_content = "Hello, World!"
@@ -99,12 +101,39 @@ def test_invalid_overwrite_value(tool, temp_env):
def test_missing_required_fields(tool, temp_env):
result = tool._run(
directory=temp_env["temp_dir"],
content=temp_env["test_content"],
overwrite=True,
with pytest.raises(TypeError, match="filename"):
tool._run(
directory=temp_env["temp_dir"],
content=temp_env["test_content"],
overwrite=True,
)
def test_missing_required_fields_via_run(tool, temp_env):
"""The public entry point fails schema validation before reaching _run."""
with pytest.raises(ValueError, match="validation failed"):
tool.run(
directory=temp_env["temp_dir"],
content=temp_env["test_content"],
overwrite=True,
)
def test_documented_positional_signature(tool, temp_env):
"""The documented (filename, content, directory) call order must work."""
result = tool._run("example.txt", "This is a test content.", "test_directory")
assert "successfully written" in result
assert read_file(os.path.join("test_directory", "example.txt")) == (
"This is a test content."
)
assert "An error occurred while accessing key: 'filename'" in result
def test_defaults_applied_through_run(tool, temp_env):
"""overwrite/directory defaults come from the schema, not from _run kwargs."""
assert "successfully written" in tool.run(filename="a.txt", content="one")
assert "already exists" in tool.run(filename="a.txt", content="two")
assert read_file("a.txt") == "one"
def test_empty_content(tool, temp_env):
@@ -196,3 +225,210 @@ def test_blocks_symlink_escape(tool, temp_env):
assert not os.path.exists(outside_file)
finally:
shutil.rmtree(outside_dir, ignore_errors=True)
def test_blocks_directory_outside_working_directory(tool, temp_env):
"""An absolute 'directory' must not escape the working directory."""
outside_dir = tempfile.mkdtemp()
outside_file = os.path.join(outside_dir, "pwned.txt")
try:
result = tool._run(
filename="pwned.txt",
directory=outside_dir,
content="should not be written",
overwrite=True,
)
assert "Error" in result
assert not os.path.exists(outside_file)
assert outside_dir not in result
# Point users at base_dir, not the process-wide escape hatch, which
# would also disable the SSRF checks on URL-fetching tools.
assert "base_dir" in result
assert "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS" not in result
finally:
shutil.rmtree(outside_dir, ignore_errors=True)
def test_blocks_directory_traversal_outside_working_directory(tool, temp_env):
"""'..' in the directory must not escape the working directory either."""
outside_dir = tempfile.mkdtemp()
outside_file = os.path.join(outside_dir, "pwned.txt")
relative = os.path.join("..", os.path.basename(outside_dir))
try:
result = tool._run(
filename="pwned.txt",
directory=relative,
content="should not be written",
overwrite=True,
)
assert "Error" in result
assert not os.path.exists(outside_file)
finally:
shutil.rmtree(outside_dir, ignore_errors=True)
def test_base_dir_widens_the_sandbox(temp_env):
"""base_dir lets a developer authorize writes outside the working directory."""
outside_dir = tempfile.mkdtemp()
try:
scoped = FileWriterTool(base_dir=outside_dir)
result = scoped._run(
filename="allowed.txt",
directory=outside_dir,
content="written",
overwrite=True,
)
assert "successfully written" in result
assert read_file(os.path.join(outside_dir, "allowed.txt")) == "written"
finally:
shutil.rmtree(outside_dir, ignore_errors=True)
def test_base_dir_anchors_relative_directories(temp_env):
"""With base_dir set, a relative (or omitted) directory resolves under it."""
outside_dir = tempfile.mkdtemp()
try:
scoped = FileWriterTool(base_dir=outside_dir)
assert "successfully written" in scoped._run(
filename="top.txt", content="top", overwrite=True
)
assert "successfully written" in scoped._run(
filename="deep.txt", directory="nested", content="deep", overwrite=True
)
assert read_file(os.path.join(outside_dir, "top.txt")) == "top"
assert read_file(os.path.join(outside_dir, "nested", "deep.txt")) == "deep"
# The working directory must not have been touched.
assert not os.path.exists(os.path.join(temp_env["temp_dir"], "top.txt"))
finally:
shutil.rmtree(outside_dir, ignore_errors=True)
def test_relative_base_dir_is_anchored_at_construction(temp_env, monkeypatch):
"""A relative base_dir must not follow a later chdir."""
allowed = os.path.join(temp_env["temp_dir"], "allowed")
os.makedirs(allowed)
nested = os.path.join(temp_env["temp_dir"], "sub")
os.makedirs(nested)
scoped = FileWriterTool(base_dir="allowed")
assert scoped.base_dir == os.path.realpath(allowed)
monkeypatch.chdir(nested)
result = scoped._run(filename="x.txt", content="written", overwrite=True)
assert "successfully written" in result
assert read_file(os.path.join(allowed, "x.txt")) == "written"
def test_base_dir_survives_a_serialization_round_trip(temp_env):
outside = tempfile.mkdtemp()
try:
restored = FileWriterTool.model_validate(
FileWriterTool(base_dir=outside).model_dump()
)
assert restored.base_dir == os.path.realpath(outside)
assert "successfully written" in restored._run(
filename="x.txt", directory=outside, content="written", overwrite=True
)
finally:
shutil.rmtree(outside, ignore_errors=True)
def test_base_dir_still_blocks_escapes(temp_env):
"""base_dir moves the sandbox; it does not remove it."""
allowed_dir = tempfile.mkdtemp()
outside_dir = tempfile.mkdtemp()
outside_file = os.path.join(outside_dir, "pwned.txt")
try:
scoped = FileWriterTool(base_dir=allowed_dir)
result = scoped._run(
filename="pwned.txt",
directory=outside_dir,
content="should not be written",
overwrite=True,
)
assert "Error" in result
assert not os.path.exists(outside_file)
finally:
shutil.rmtree(allowed_dir, ignore_errors=True)
shutil.rmtree(outside_dir, ignore_errors=True)
def test_escape_hatch_allows_writes_outside_working_directory(
tool, temp_env, monkeypatch
):
monkeypatch.setenv("CREWAI_TOOLS_ALLOW_UNSAFE_PATHS", "true")
outside_dir = tempfile.mkdtemp()
try:
result = tool._run(
filename="allowed.txt",
directory=outside_dir,
content="written",
overwrite=True,
)
assert "successfully written" in result
assert read_file(os.path.join(outside_dir, "allowed.txt")) == "written"
finally:
shutil.rmtree(outside_dir, ignore_errors=True)
def test_creates_parents_for_nested_filename(tool, temp_env):
"""Subdirectories in 'filename' are created even without a 'directory'."""
result = tool._run(
filename=os.path.join("sub", "dir", "x.txt"),
content=temp_env["test_content"],
overwrite=True,
)
assert "successfully written" in result
assert read_file(os.path.join("sub", "dir", "x.txt")) == temp_env["test_content"]
def test_directory_that_is_an_existing_file(tool, temp_env):
"""The error must describe the real problem, not a bogus overwrite failure."""
with open("notadir", "w") as f:
f.write("x")
result = tool._run(
filename="f.txt",
directory="notadir",
content=temp_env["test_content"],
overwrite=True,
)
assert "Error" in result
assert "a file already exists where a directory is needed" in result
assert "overwrite" not in result
@pytest.mark.parametrize(
("filename", "directory"),
[("a\x00b.txt", "./"), ("ok.txt", "d\x00ir")],
)
def test_null_byte_returns_error_instead_of_raising(tool, filename, directory):
"""_run's contract is to return a descriptive string for bad input."""
result = tool._run(
filename=filename, directory=directory, content="x", overwrite=True
)
assert "Error" in result
def test_writes_utf8_by_default(tool, temp_env):
content = "café — 日本語 — 🚀"
tool._run(filename=temp_env["test_file"], content=content, overwrite=True)
with open(temp_env["test_file"], "rb") as f:
assert f.read() == content.encode("utf-8")
def test_encoding_is_configurable(temp_env):
content = "café"
FileWriterTool(encoding="latin-1")._run(
filename=temp_env["test_file"], content=content, overwrite=True
)
with open(temp_env["test_file"], "rb") as f:
assert f.read() == content.encode("latin-1")

View File

@@ -8,6 +8,7 @@ import pytest
from crewai_tools.security.safe_path import (
format_path_for_display,
format_sandbox_error,
validate_directory_path,
validate_file_path,
validate_url,
@@ -191,3 +192,20 @@ class TestValidateUrl:
# file:// would normally be blocked
result = validate_url("file:///etc/passwd")
assert result == "file:///etc/passwd"
class TestFormatSandboxError:
def test_replaces_bypass_advice_with_remedy(self, tmp_path):
with pytest.raises(ValueError) as exc:
validate_file_path(str(tmp_path.parent / "outside.txt"), str(tmp_path))
message = format_sandbox_error(exc.value, "Pass base_dir to widen it.")
assert "outside the allowed directory" in message
assert "Pass base_dir to widen it." in message
assert "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS" not in message
def test_leaves_unrelated_errors_intact(self):
message = format_sandbox_error(ValueError("something else"), "Do this.")
assert message == "something else Do this."

View File

@@ -9931,7 +9931,7 @@
}
},
{
"description": "A tool that reads the content of a file. To use this tool, provide a 'file_path' parameter with the path to the file you want to read. Optionally, provide 'start_line' to start reading from a specific line and 'line_count' to limit the number of lines read.",
"description": "A tool that reads the content of a file. To use this tool, provide a 'file_path' parameter with the path to the file you want to read. Reads are confined to the tool's allowed directory; a path that resolves outside it is rejected. Optionally, provide 'start_line' to start reading from a specific line and 'line_count' to limit the number of lines read.",
"env_vars": [],
"humanized_name": "Read a file's content",
"init_params_schema": {
@@ -9972,8 +9972,25 @@
"type": "object"
}
},
"description": "A tool for reading file contents.\n\nThis tool inherits its schema handling from BaseTool to avoid recursive schema\ndefinition issues. The args_schema is set to FileReadToolSchema which defines\nthe required file_path parameter. The schema should not be overridden in the\nconstructor as it would break the inheritance chain and cause infinite loops.\n\nThe tool supports two ways of specifying the file path:\n1. At construction time via the file_path parameter\n2. At runtime via the file_path parameter in the tool's input\n\nArgs:\n file_path (Optional[str]): Path to the file to be read. If provided,\n this becomes the default file path for the tool.\n **kwargs: Additional keyword arguments passed to BaseTool.\n\nExample:\n >>> tool = FileReadTool(file_path=\"/path/to/file.txt\")\n >>> content = tool.run() # Reads /path/to/file.txt\n >>> content = tool.run(file_path=\"/path/to/other.txt\") # Reads other.txt\n >>> content = tool.run(\n ... file_path=\"/path/to/file.txt\", start_line=100, line_count=50\n ... ) # Reads lines 100-149",
"description": "A tool for reading file contents.\n\nThis tool inherits its schema handling from BaseTool to avoid recursive schema\ndefinition issues. The args_schema is set to FileReadToolSchema, whose\nfile_path parameter is optional so the tool's default file can be read by\nomitting it. The schema should not be overridden in the constructor as it\nwould break the inheritance chain and cause infinite loops.\n\nThe tool supports two ways of specifying the file path:\n1. At construction time via the file_path parameter\n2. At runtime via the file_path parameter in the tool's input\n\nPaths supplied at runtime must resolve inside ``base_dir`` (the current\nworking directory by default), since they are typically chosen by an LLM.\nA ``file_path`` given at construction time is developer-declared intent and\nis always allowed past the containment check, even when it lives outside\n``base_dir`` (the read itself can still fail). It is pinned at\nconstruction, so a later chdir cannot repoint it, and it can be addressed\neither by omitting ``file_path`` or by the label shown in the description.\n\nArgs:\n file_path (Optional[str]): Path to the file to be read. If provided,\n this becomes the default file path for the tool.\n base_dir (Optional[str]): Directory that runtime paths must stay inside.\n Defaults to the current working directory.\n encoding (str): Text encoding used to decode the file. Defaults to UTF-8.\n **kwargs: Additional keyword arguments passed to BaseTool.\n\nExample:\n >>> tool = FileReadTool(file_path=\"/path/to/file.txt\")\n >>> content = tool.run() # Reads /path/to/file.txt\n >>> content = tool.run(file_path=\"/path/to/other.txt\") # Reads other.txt\n >>> content = tool.run(\n ... file_path=\"/path/to/file.txt\", start_line=100, line_count=50\n ... ) # Reads lines 100-149\n >>> # Widen the sandbox so the agent may read anything under /data:\n >>> tool = FileReadTool(base_dir=\"/data\")",
"properties": {
"base_dir": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Base Dir"
},
"encoding": {
"default": "utf-8",
"title": "Encoding",
"type": "string"
},
"file_path": {
"anyOf": [
{
@@ -9997,9 +10014,17 @@
"description": "Input for FileReadTool.",
"properties": {
"file_path": {
"description": "Mandatory file full path to read the file",
"title": "File Path",
"type": "string"
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Full path of the file to read. Omit it to read the tool's default file, which only works when one was configured.",
"title": "File Path"
},
"line_count": {
"anyOf": [
@@ -10028,15 +10053,12 @@
"title": "Start Line"
}
},
"required": [
"file_path"
],
"title": "FileReadToolSchema",
"type": "object"
}
},
{
"description": "A tool to write content to a specified file. Accepts filename, content, and optionally a directory path and overwrite flag as input.",
"description": "A tool to write content to a specified file. Accepts filename, content, and optionally a directory path and overwrite flag as input. Writes are confined to the tool's allowed directory; a filename or directory that resolves outside it is rejected.",
"env_vars": [],
"humanized_name": "File Writer Tool",
"init_params_schema": {
@@ -10077,7 +10099,26 @@
"type": "object"
}
},
"properties": {},
"description": "A tool for writing text content to a file.\n\nWrites are confined to ``base_dir`` (the current working directory by\ndefault), because the target directory and filename are typically chosen by\nan LLM at runtime. Set ``base_dir`` to widen that sandbox deliberately.\n\nArgs:\n base_dir (Optional[str]): Directory that writes must stay inside.\n Defaults to the current working directory.\n encoding (str): Text encoding used to write the file. Defaults to UTF-8.\n\nExample:\n >>> tool = FileWriterTool()\n >>> tool.run(filename=\"report.md\", content=\"# Report\", overwrite=True)\n >>> # Allow the agent to write anywhere under /var/output:\n >>> tool = FileWriterTool(base_dir=\"/var/output\")",
"properties": {
"base_dir": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Base Dir"
},
"encoding": {
"default": "utf-8",
"title": "Encoding",
"type": "string"
}
},
"required": [],
"title": "FileWriterTool",
"type": "object"
@@ -10085,8 +10126,10 @@
"name": "FileWriterTool",
"package_dependencies": [],
"run_params_schema": {
"description": "Input for FileWriterTool.",
"properties": {
"content": {
"description": "The text content to write to the file.",
"title": "Content",
"type": "string"
},
@@ -10100,9 +10143,11 @@
}
],
"default": "./",
"description": "Directory to write the file into. A relative path resolves inside the tool's allowed directory, and defaults to its root. Created if it does not exist.",
"title": "Directory"
},
"filename": {
"description": "Name of the file to write, relative to 'directory'. May include subdirectories, which are created if they do not exist.",
"title": "Filename",
"type": "string"
},
@@ -10116,6 +10161,7 @@
}
],
"default": false,
"description": "Whether to replace the file when it already exists. Accepts true/false (also yes/no, on/off, 1/0). Defaults to false, which reports an error instead of replacing existing content.",
"title": "Overwrite"
}
},