mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-28 10:09:21 +00:00
fix(tools): sandbox FileWriterTool writes and fix file tool rough edges (#6692)
* 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:
@@ -11,7 +11,7 @@ mode: "wide"
|
||||
لا نزال نعمل على تحسين الأدوات، لذا قد يحدث سلوك غير متوقع أو تغييرات في المستقبل.
|
||||
</Note>
|
||||
|
||||
تمثل أداة FileReadTool مفهومياً مجموعة من الوظائف ضمن حزمة crewai_tools تهدف إلى تسهيل قراءة الملفات واسترجاع المحتوى. تتضمن هذه المجموعة أدوات لمعالجة ملفات نصية دفعية، وقراءة ملفات التكوين أثناء التشغيل، واستيراد البيانات للتحليلات. تدعم مجموعة متنوعة من صيغ الملفات النصية مثل `.txt` و `.csv` و `.json` وغيرها. اعتماداً على نوع الملف، توفر المجموعة وظائف متخصصة، مثل تحويل محتوى JSON إلى قاموس Python لسهولة الاستخدام.
|
||||
تمثل أداة FileReadTool مفهوميًا مجموعة من الوظائف ضمن حزمة crewai_tools تهدف إلى تسهيل قراءة الملفات واسترجاع المحتوى. تتضمن هذه المجموعة أدوات لمعالجة ملفات نصية دفعية، وقراءة ملفات التكوين أثناء التشغيل، واستيراد البيانات للتحليلات. تدعم مجموعة متنوعة من صيغ الملفات النصية مثل `.txt` و `.csv` و `.json` وغيرها. يُعاد المحتوى دائمًا نصًا عاديًا.
|
||||
|
||||
## التثبيت
|
||||
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ mode: "wide"
|
||||
|
||||
FileReadTool은 crewai_tools 패키지 내에서 파일 읽기와 콘텐츠 검색을 용이하게 하는 기능 모음입니다.
|
||||
이 모음에는 배치 텍스트 파일 처리, 런타임 구성 파일 읽기, 분석을 위한 데이터 가져오기 등 다양한 도구가 포함되어 있습니다.
|
||||
`.txt`, `.csv`, `.json` 등 다양한 텍스트 기반 파일 형식을 지원합니다. 파일 유형에 따라 이 모음은
|
||||
JSON 콘텐츠를 Python 딕셔너리로 변환하여 사용을 쉽게 하는 등 특화된 기능을 제공합니다.
|
||||
`.txt`, `.csv`, `.json` 등 다양한 텍스트 기반 파일 형식을 지원합니다. 콘텐츠는 항상 일반 텍스트로 반환됩니다.
|
||||
|
||||
## 설치
|
||||
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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}. "
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user