diff --git a/docs/edge/ar/tools/file-document/filereadtool.mdx b/docs/edge/ar/tools/file-document/filereadtool.mdx index 10053a735..33db3e02a 100644 --- a/docs/edge/ar/tools/file-document/filereadtool.mdx +++ b/docs/edge/ar/tools/file-document/filereadtool.mdx @@ -11,7 +11,7 @@ mode: "wide" لا نزال نعمل على تحسين الأدوات، لذا قد يحدث سلوك غير متوقع أو تغييرات في المستقبل. -تمثل أداة FileReadTool مفهومياً مجموعة من الوظائف ضمن حزمة crewai_tools تهدف إلى تسهيل قراءة الملفات واسترجاع المحتوى. تتضمن هذه المجموعة أدوات لمعالجة ملفات نصية دفعية، وقراءة ملفات التكوين أثناء التشغيل، واستيراد البيانات للتحليلات. تدعم مجموعة متنوعة من صيغ الملفات النصية مثل `.txt` و `.csv` و `.json` وغيرها. اعتماداً على نوع الملف، توفر المجموعة وظائف متخصصة، مثل تحويل محتوى JSON إلى قاموس Python لسهولة الاستخدام. +تمثل أداة FileReadTool مفهوميًا مجموعة من الوظائف ضمن حزمة crewai_tools تهدف إلى تسهيل قراءة الملفات واسترجاع المحتوى. تتضمن هذه المجموعة أدوات لمعالجة ملفات نصية دفعية، وقراءة ملفات التكوين أثناء التشغيل، واستيراد البيانات للتحليلات. تدعم مجموعة متنوعة من صيغ الملفات النصية مثل `.txt` و `.csv` و `.json` وغيرها. يُعاد المحتوى دائمًا نصًا عاديًا. ## التثبيت diff --git a/docs/edge/ar/tools/file-document/filewritetool.mdx b/docs/edge/ar/tools/file-document/filewritetool.mdx index fc01a9c0a..c38191c26 100644 --- a/docs/edge/ar/tools/file-document/filewritetool.mdx +++ b/docs/edge/ar/tools/file-document/filewritetool.mdx @@ -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) ``` diff --git a/docs/edge/en/tools/file-document/filereadtool.mdx b/docs/edge/en/tools/file-document/filereadtool.mdx index 43646c8dc..0fd748a71 100644 --- a/docs/edge/en/tools/file-document/filereadtool.mdx +++ b/docs/edge/en/tools/file-document/filereadtool.mdx @@ -11,10 +11,12 @@ mode: "wide" We are still working on improving tools, so there might be unexpected behavior or changes in the future. -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. \ No newline at end of file +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`. diff --git a/docs/edge/en/tools/file-document/filewritetool.mdx b/docs/edge/en/tools/file-document/filewritetool.mdx index f8bc464ce..812b276d2 100644 --- a/docs/edge/en/tools/file-document/filewritetool.mdx +++ b/docs/edge/en/tools/file-document/filewritetool.mdx @@ -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') +``` + + + 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`. + ## Conclusion diff --git a/docs/edge/ko/tools/file-document/filereadtool.mdx b/docs/edge/ko/tools/file-document/filereadtool.mdx index 4559f6250..6dbeea186 100644 --- a/docs/edge/ko/tools/file-document/filereadtool.mdx +++ b/docs/edge/ko/tools/file-document/filereadtool.mdx @@ -13,8 +13,7 @@ mode: "wide" FileReadTool은 crewai_tools 패키지 내에서 파일 읽기와 콘텐츠 검색을 용이하게 하는 기능 모음입니다. 이 모음에는 배치 텍스트 파일 처리, 런타임 구성 파일 읽기, 분석을 위한 데이터 가져오기 등 다양한 도구가 포함되어 있습니다. -`.txt`, `.csv`, `.json` 등 다양한 텍스트 기반 파일 형식을 지원합니다. 파일 유형에 따라 이 모음은 -JSON 콘텐츠를 Python 딕셔너리로 변환하여 사용을 쉽게 하는 등 특화된 기능을 제공합니다. +`.txt`, `.csv`, `.json` 등 다양한 텍스트 기반 파일 형식을 지원합니다. 콘텐츠는 항상 일반 텍스트로 반환됩니다. ## 설치 diff --git a/docs/edge/ko/tools/file-document/filewritetool.mdx b/docs/edge/ko/tools/file-document/filewritetool.mdx index 77f5a6210..11a8bd1a0 100644 --- a/docs/edge/ko/tools/file-document/filewritetool.mdx +++ b/docs/edge/ko/tools/file-document/filewritetool.mdx @@ -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) ``` diff --git a/docs/edge/pt-BR/tools/file-document/filereadtool.mdx b/docs/edge/pt-BR/tools/file-document/filereadtool.mdx index 914e9355e..e64fadd9c 100644 --- a/docs/edge/pt-BR/tools/file-document/filereadtool.mdx +++ b/docs/edge/pt-BR/tools/file-document/filereadtool.mdx @@ -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 diff --git a/docs/edge/pt-BR/tools/file-document/filewritetool.mdx b/docs/edge/pt-BR/tools/file-document/filewritetool.mdx index 9fddce07f..556a9e51c 100644 --- a/docs/edge/pt-BR/tools/file-document/filewritetool.mdx +++ b/docs/edge/pt-BR/tools/file-document/filewritetool.mdx @@ -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) ``` diff --git a/lib/crewai-tools/src/crewai_tools/security/safe_path.py b/lib/crewai-tools/src/crewai_tools/security/safe_path.py index 986cf799a..b03f614fc 100644 --- a/lib/crewai-tools/src/crewai_tools/security/safe_path.py +++ b/lib/crewai-tools/src/crewai_tools/security/safe_path.py @@ -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") diff --git a/lib/crewai-tools/src/crewai_tools/tools/file_read_tool/README.md b/lib/crewai-tools/src/crewai_tools/tools/file_read_tool/README.md index 7b8a15488..10be336c2 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/file_read_tool/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/file_read_tool/README.md @@ -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`. diff --git a/lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py b/lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py index 5aff0c72f..5814ebbbb 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/file_read_tool/file_read_tool.py @@ -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}. " diff --git a/lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/README.md b/lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/README.md index e93e5c682..479e5f103 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/README.md @@ -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. diff --git a/lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py b/lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py index 8ab0c2004..86a65b233 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/file_writer_tool/file_writer_tool.py @@ -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}" diff --git a/lib/crewai-tools/tests/file_read_tool_test.py b/lib/crewai-tools/tests/file_read_tool_test.py index ab4c8e5a6..328950c4c 100644 --- a/lib/crewai-tools/tests/file_read_tool_test.py +++ b/lib/crewai-tools/tests/file_read_tool_test.py @@ -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 diff --git a/lib/crewai-tools/tests/tools/test_file_writer_tool.py b/lib/crewai-tools/tests/tools/test_file_writer_tool.py index 77f91f152..a9caa979e 100644 --- a/lib/crewai-tools/tests/tools/test_file_writer_tool.py +++ b/lib/crewai-tools/tests/tools/test_file_writer_tool.py @@ -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") diff --git a/lib/crewai-tools/tests/utilities/test_safe_path.py b/lib/crewai-tools/tests/utilities/test_safe_path.py index 1a0faa14b..2243ba971 100644 --- a/lib/crewai-tools/tests/utilities/test_safe_path.py +++ b/lib/crewai-tools/tests/utilities/test_safe_path.py @@ -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." diff --git a/lib/crewai-tools/tool.specs.json b/lib/crewai-tools/tool.specs.json index 97d1242f0..87319d6ba 100644 --- a/lib/crewai-tools/tool.specs.json +++ b/lib/crewai-tools/tool.specs.json @@ -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" } },