Skip to content

Commit 6e8b464

Browse files
fix(crewai-tools): let DirectoryReadTool use a fixed directory outside cwd
PR #6248 confined file tools to an allow-listed root to block path traversal. FileReadTool and FileWriterTool pin a declared, construction-time path as always-allowed developer intent, resolved once so a later chdir cannot move it, while runtime/LLM-supplied paths still go through the containment check. DirectoryReadTool was not updated to match. Its `_run` validated the directory via `validate_directory_path(directory)` with no base_dir, which defaults to `os.getcwd()`. Any fixed `directory` passed at construction time that is not the current working directory is rejected, and worse, with an unhandled ValueError instead of a graceful error string, unlike its sibling tools. This breaks the tool's own headline documented use case, `DirectoryReadTool(directory="/some/dir")`, whenever that directory is not the process's cwd. This adds a `base_dir` parameter mirroring FileReadTool, pins the construction-time `directory` as `_declared_realpath` so it always bypasses the containment check (developer-declared intent, resolved once so a later chdir cannot move it), while runtime/LLM-supplied directories still go through validate_directory_path and remain sandboxed. Missing-directory and OS errors are now caught and returned as graceful error strings instead of raising.
1 parent 18c52c4 commit 6e8b464

2 files changed

Lines changed: 178 additions & 9 deletions

File tree

lib/crewai-tools/src/crewai_tools/tools/directory_read_tool/directory_read_tool.py

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
from typing import Any
33

44
from crewai.tools import BaseTool
5-
from pydantic import BaseModel, Field
5+
from pydantic import BaseModel, Field, PrivateAttr
66

7-
from crewai_tools.security.safe_path import validate_directory_path
7+
from crewai_tools.security.safe_path import (
8+
format_error_for_display,
9+
format_sandbox_error,
10+
validate_directory_path,
11+
)
812

913

1014
class FixedDirectoryReadToolSchema(BaseModel):
@@ -18,20 +22,59 @@ class DirectoryReadToolSchema(FixedDirectoryReadToolSchema):
1822

1923

2024
class DirectoryReadTool(BaseTool):
25+
"""A tool for recursively listing a directory's content.
26+
27+
A ``directory`` given at construction time is developer-declared intent
28+
and is always allowed past the containment check, even when it lives
29+
outside ``base_dir`` (the tool's operation can still fail if the path
30+
does not exist). It is pinned at construction, so a later chdir cannot
31+
repoint it. A directory supplied at runtime -- typically chosen by an
32+
LLM -- must resolve inside ``base_dir`` (the current working directory
33+
by default).
34+
35+
Args:
36+
directory (Optional[str]): Directory to list. If provided, this
37+
becomes the fixed directory for the tool and the LLM can no
38+
longer choose a different one.
39+
base_dir (Optional[str]): Directory that runtime-supplied paths
40+
must stay inside. Defaults to the current working directory.
41+
**kwargs: Additional keyword arguments passed to BaseTool.
42+
"""
43+
2144
name: str = "List files in directory"
2245
description: str = (
2346
"A tool that can be used to recursively list a directory's content."
2447
)
2548
args_schema: type[BaseModel] = DirectoryReadToolSchema
2649
directory: str | None = None
50+
base_dir: str | None = None
51+
52+
_declared_realpath: str | None = PrivateAttr(default=None)
2753

28-
def __init__(self, directory: str | None = None, **kwargs: Any) -> None:
54+
def __init__(
55+
self,
56+
directory: str | None = None,
57+
base_dir: str | None = None,
58+
**kwargs: Any,
59+
) -> None:
2960
super().__init__(**kwargs)
61+
if base_dir is not None:
62+
base_dir = os.path.realpath(base_dir)
63+
self.base_dir = base_dir
64+
3065
if directory is not None:
3166
self.directory = directory
3267
self.description = f"A tool that can be used to list {directory}'s content."
3368
self.args_schema = FixedDirectoryReadToolSchema
3469
self._generate_description()
70+
# Anchor now so a later chdir cannot move the declared directory,
71+
# and so it is never rejected by the base_dir containment check
72+
# below -- the developer named it explicitly.
73+
self._declared_realpath = (
74+
os.path.realpath(directory)
75+
if os.path.isabs(directory)
76+
else os.path.realpath(os.path.join(base_dir or os.getcwd(), directory))
77+
)
3578

3679
def _run(
3780
self,
@@ -41,13 +84,29 @@ def _run(
4184
if directory is None:
4285
raise ValueError("Directory must be provided.")
4386

44-
directory = validate_directory_path(directory)
87+
if self._declared_realpath is not None and directory == self.directory:
88+
directory = self._declared_realpath
89+
if not os.path.isdir(directory):
90+
return f"Error: '{directory}' is not a directory."
91+
else:
92+
try:
93+
directory = validate_directory_path(directory, self.base_dir)
94+
except ValueError as e:
95+
return "Error: Invalid directory: " + format_sandbox_error(
96+
e,
97+
"Pass base_dir to DirectoryReadTool to allow listing another "
98+
"directory tree.",
99+
)
100+
45101
if directory[-1] == "/":
46102
directory = directory[:-1]
47-
files_list = [
48-
f"{directory}/{(os.path.join(root, filename).replace(directory, '').lstrip(os.path.sep))}"
49-
for root, dirs, files in os.walk(directory)
50-
for filename in files
51-
]
103+
try:
104+
files_list = [
105+
f"{directory}/{(os.path.join(root, filename).replace(directory, '').lstrip(os.path.sep))}"
106+
for root, dirs, files in os.walk(directory)
107+
for filename in files
108+
]
109+
except OSError as e:
110+
return f"Error: Could not list '{directory}'. {format_error_for_display(e)}"
52111
files = "\n- ".join(files_list)
53112
return f"File paths: \n-{files}"
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Regression tests for DirectoryReadTool.
2+
3+
Covers a bug where a *developer-configured* fixed ``directory`` outside the
4+
process's current working directory was always rejected as "outside the
5+
allowed directory", because ``_run`` validated it against
6+
``validate_directory_path``'s default ``base_dir`` (``os.getcwd()``) with no
7+
way to widen or pin it -- unlike the sibling ``FileReadTool``/
8+
``FileWriterTool``, which pin a declared path at construction time.
9+
10+
This made the tool's most basic documented use case --
11+
``DirectoryReadTool(directory="/some/other/dir")`` -- unusable whenever that
12+
directory was not the current working directory, and raised an *unhandled*
13+
``ValueError`` instead of returning a graceful error string.
14+
"""
15+
16+
import os
17+
18+
import pytest
19+
20+
from crewai_tools.tools.directory_read_tool.directory_read_tool import (
21+
DirectoryReadTool,
22+
)
23+
24+
25+
@pytest.fixture
26+
def outside_dir(tmp_path):
27+
"""A directory that is guaranteed not to be the cwd used by tests."""
28+
target = tmp_path / "outside"
29+
target.mkdir()
30+
(target / "a.txt").write_text("hello")
31+
(target / "nested").mkdir()
32+
(target / "nested" / "b.txt").write_text("world")
33+
return target
34+
35+
36+
def test_fixed_directory_outside_cwd_is_listed(outside_dir, monkeypatch):
37+
"""A directory declared at construction time must work even when it is
38+
not the current working directory (the primary documented use case)."""
39+
monkeypatch.chdir(outside_dir.parent)
40+
tool = DirectoryReadTool(directory=str(outside_dir))
41+
42+
result = tool._run()
43+
44+
assert "Error" not in result
45+
assert "a.txt" in result
46+
assert "b.txt" in result
47+
48+
49+
def test_fixed_directory_survives_chdir(outside_dir, tmp_path, monkeypatch):
50+
"""The declared directory is pinned at construction time, like
51+
FileReadTool's declared file_path, so a later chdir cannot break it."""
52+
monkeypatch.chdir(outside_dir)
53+
tool = DirectoryReadTool(directory=str(outside_dir))
54+
55+
other_cwd = tmp_path / "elsewhere"
56+
other_cwd.mkdir()
57+
monkeypatch.chdir(other_cwd)
58+
59+
result = tool._run()
60+
61+
assert "Error" not in result
62+
assert "a.txt" in result
63+
64+
65+
def test_runtime_directory_outside_base_dir_is_rejected(outside_dir, tmp_path, monkeypatch):
66+
"""Sandboxing must be preserved for LLM-supplied paths at runtime: only
67+
the declared/construction-time directory bypasses containment."""
68+
cwd = tmp_path / "cwd"
69+
cwd.mkdir()
70+
monkeypatch.chdir(cwd)
71+
tool = DirectoryReadTool()
72+
73+
result = tool._run(directory=str(outside_dir))
74+
75+
assert "Error" in result
76+
assert "outside the allowed directory" in result
77+
78+
79+
def test_runtime_directory_inside_cwd_is_listed(tmp_path, monkeypatch):
80+
monkeypatch.chdir(tmp_path)
81+
(tmp_path / "f.txt").write_text("hi")
82+
tool = DirectoryReadTool()
83+
84+
result = tool._run(directory=".")
85+
86+
assert "Error" not in result
87+
assert "f.txt" in result
88+
89+
90+
def test_base_dir_widens_runtime_sandbox(outside_dir, tmp_path, monkeypatch):
91+
cwd = tmp_path / "cwd"
92+
cwd.mkdir()
93+
monkeypatch.chdir(cwd)
94+
tool = DirectoryReadTool(base_dir=str(outside_dir.parent))
95+
96+
result = tool._run(directory=str(outside_dir))
97+
98+
assert "Error" not in result
99+
assert "a.txt" in result
100+
101+
102+
def test_missing_directory_returns_error_not_exception(tmp_path, monkeypatch):
103+
"""A nonexistent fixed directory must return a graceful error, not raise."""
104+
monkeypatch.chdir(tmp_path)
105+
missing = tmp_path / "does-not-exist"
106+
tool = DirectoryReadTool(directory=str(missing))
107+
108+
result = tool._run()
109+
110+
assert "Error" in result

0 commit comments

Comments
 (0)