Skip to content

Commit e0df891

Browse files
theCyberTechclaude
andcommitted
fix: confine file tools to an allow-listed root to block path traversal
LLM/prompt-injection-controlled file paths could escape the working directory. The RAG search tools and FileReadTool already routed through validate_file_path, but FileWriterTool only checked that `filename` did not escape the caller-supplied `directory` — and `directory` is itself LLM-controlled, so an agent fed untrusted content could be steered into writing anywhere on disk (e.g. ~/.ssh/authorized_keys). - safe_path: replace the single base_dir cwd jail with a deny-by-default allow-list of roots, sourced from cwd + CREWAI_TOOLS_ALLOWED_DIRS + a caller-passed allowed_dirs. Backward compatible for existing callers. - FileWriterTool: route the resolved write target through validate_file_path so writes are confined to an allow-listed root regardless of the directory argument. - Tests: allow-list extension via env/param, deny-by-default, multi-root, and a regression test for the unbounded-directory write. BREAKING: FileWriterTool no longer writes to arbitrary absolute directories by default. Set CREWAI_TOOLS_ALLOWED_DIRS to permit out-of-cwd writes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cf04181 commit e0df891

4 files changed

Lines changed: 202 additions & 45 deletions

File tree

lib/crewai-tools/src/crewai_tools/security/safe_path.py

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,55 @@
2020
logger = logging.getLogger(__name__)
2121

2222
_UNSAFE_PATHS_ENV = "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS"
23+
_ALLOWED_DIRS_ENV = "CREWAI_TOOLS_ALLOWED_DIRS"
24+
25+
26+
def _get_allowed_roots(
27+
base_dir: str | None = None,
28+
allowed_dirs: list[str] | None = None,
29+
) -> list[str]:
30+
"""Build the deny-by-default set of allowed root directories.
31+
32+
Roots are drawn from, in order:
33+
34+
1. ``base_dir`` (defaults to the current working directory),
35+
2. the ``CREWAI_TOOLS_ALLOWED_DIRS`` environment variable, split on
36+
``os.pathsep``,
37+
3. the caller-supplied ``allowed_dirs`` list.
38+
39+
Every root is resolved with :func:`os.path.realpath` so a symlinked root
40+
is compared by its real location. Empty entries are ignored and duplicates
41+
are collapsed while preserving order. The first element is always the
42+
primary root used to resolve relative candidate paths.
43+
"""
44+
raw_roots: list[str] = [base_dir if base_dir is not None else os.getcwd()]
45+
46+
env_dirs = os.environ.get(_ALLOWED_DIRS_ENV, "")
47+
if env_dirs:
48+
raw_roots.extend(d for d in env_dirs.split(os.pathsep) if d)
49+
50+
if allowed_dirs:
51+
raw_roots.extend(d for d in allowed_dirs if d)
52+
53+
resolved: list[str] = []
54+
seen: set[str] = set()
55+
for root in raw_roots:
56+
real = os.path.realpath(root)
57+
if real not in seen:
58+
seen.add(real)
59+
resolved.append(real)
60+
return resolved
61+
62+
63+
def _is_within_root(resolved_path: str, resolved_root: str) -> bool:
64+
"""Return True if *resolved_path* equals *resolved_root* or lives beneath it.
65+
66+
When ``resolved_root`` already ends with a separator (e.g. the filesystem
67+
root ``"/"``), appending ``os.sep`` would double it, so the root is used
68+
as-is for the prefix in that case.
69+
"""
70+
prefix = resolved_root if resolved_root.endswith(os.sep) else resolved_root + os.sep
71+
return resolved_path == resolved_root or resolved_path.startswith(prefix)
2372

2473

2574
def format_path_for_display(path: str, base_dir: str | None = None) -> str:
@@ -52,21 +101,32 @@ def _is_escape_hatch_enabled() -> bool:
52101
return os.environ.get(_UNSAFE_PATHS_ENV, "").lower() in ("true", "1", "yes")
53102

54103

55-
def validate_file_path(path: str, base_dir: str | None = None) -> str:
104+
def validate_file_path(
105+
path: str,
106+
base_dir: str | None = None,
107+
*,
108+
allowed_dirs: list[str] | None = None,
109+
) -> str:
56110
"""Validate that a file path is safe to read.
57111
58112
Resolves symlinks and ``..`` components, then checks that the resolved
59-
path falls within *base_dir* (defaults to the current working directory).
113+
path falls within at least one allowed root directory. The allow-list is
114+
built from *base_dir* (defaults to the current working directory), the
115+
``CREWAI_TOOLS_ALLOWED_DIRS`` environment variable, and *allowed_dirs* —
116+
see :func:`_get_allowed_roots`. Access is denied by default for anything
117+
outside that set.
60118
61119
Args:
62120
path: The file path to validate.
63-
base_dir: Allowed root directory. Defaults to ``os.getcwd()``.
121+
base_dir: Primary allowed root. Defaults to ``os.getcwd()`` and is
122+
used to resolve relative ``path`` values.
123+
allowed_dirs: Additional allowed root directories.
64124
65125
Returns:
66126
The resolved, validated absolute path.
67127
68128
Raises:
69-
ValueError: If the path escapes the allowed directory.
129+
ValueError: If the path escapes every allowed directory.
70130
"""
71131
if _is_escape_hatch_enabled():
72132
logger.warning(
@@ -76,46 +136,47 @@ def validate_file_path(path: str, base_dir: str | None = None) -> str:
76136
)
77137
return os.path.realpath(path)
78138

79-
if base_dir is None:
80-
base_dir = os.getcwd()
139+
allowed_roots = _get_allowed_roots(base_dir, allowed_dirs)
140+
primary_root = allowed_roots[0]
81141

82-
resolved_base = os.path.realpath(base_dir)
83142
resolved_path = os.path.realpath(
84-
os.path.join(resolved_base, path) if not os.path.isabs(path) else path
143+
path if os.path.isabs(path) else os.path.join(primary_root, path)
85144
)
86145

87-
# Ensure the resolved path is within the base directory.
88-
# When resolved_base already ends with a separator (e.g. the filesystem
89-
# root "/"), appending os.sep would double it ("//"), so use the base
90-
# as-is in that case.
91-
prefix = resolved_base if resolved_base.endswith(os.sep) else resolved_base + os.sep
92-
if not resolved_path.startswith(prefix) and resolved_path != resolved_base:
93-
raise ValueError(
94-
f"Path '{format_path_for_display(resolved_path, resolved_base)}' is "
95-
f"outside the allowed directory. "
96-
f"Set {_UNSAFE_PATHS_ENV}=true to bypass this check."
97-
)
146+
if any(_is_within_root(resolved_path, root) for root in allowed_roots):
147+
return resolved_path
98148

99-
return resolved_path
149+
raise ValueError(
150+
f"Path '{format_path_for_display(resolved_path, primary_root)}' is "
151+
f"outside the allowed directories. "
152+
f"Add the directory via {_ALLOWED_DIRS_ENV}, or set "
153+
f"{_UNSAFE_PATHS_ENV}=true to bypass this check."
154+
)
100155

101156

102-
def validate_directory_path(path: str, base_dir: str | None = None) -> str:
157+
def validate_directory_path(
158+
path: str,
159+
base_dir: str | None = None,
160+
*,
161+
allowed_dirs: list[str] | None = None,
162+
) -> str:
103163
"""Validate that a directory path is safe to read.
104164
105165
Same as :func:`validate_file_path` but also checks that the path
106166
is an existing directory.
107167
108168
Args:
109169
path: The directory path to validate.
110-
base_dir: Allowed root directory. Defaults to ``os.getcwd()``.
170+
base_dir: Primary allowed root. Defaults to ``os.getcwd()``.
171+
allowed_dirs: Additional allowed root directories.
111172
112173
Returns:
113174
The resolved, validated absolute path.
114175
115176
Raises:
116-
ValueError: If the path escapes the allowed directory or is not a directory.
177+
ValueError: If the path escapes every allowed directory or is not a directory.
117178
"""
118-
validated = validate_file_path(path, base_dir)
179+
validated = validate_file_path(path, base_dir, allowed_dirs=allowed_dirs)
119180
if not os.path.isdir(validated):
120181
raise ValueError(f"Path '{validated}' is not a directory.")
121182
return validated

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

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import os
2-
from pathlib import Path
32
from typing import Any
43

54
from crewai.tools import BaseTool
@@ -8,6 +7,7 @@
87
from crewai_tools.security.safe_path import (
98
format_error_for_display,
109
format_path_for_display,
10+
validate_file_path,
1111
)
1212

1313

@@ -41,22 +41,27 @@ def _run(self, **kwargs: Any) -> str:
4141

4242
filepath = os.path.join(directory, filename)
4343

44-
# Prevent path traversal: the resolved path must be strictly inside
45-
# filename, and symlink escapes regardless of how directory is set.
46-
# is_relative_to() does a proper path-component comparison that is
47-
# safe on case-insensitive filesystems and avoids the "// " edge case
48-
# We also reject the case where filepath resolves to the directory
49-
# itself, since that is not a valid file target.
50-
real_directory = Path(directory).resolve()
51-
real_filepath = Path(filepath).resolve()
52-
display_filepath = format_path_for_display(
53-
str(real_filepath), str(real_directory)
54-
)
55-
if (
56-
not real_filepath.is_relative_to(real_directory)
57-
or real_filepath == real_directory
58-
):
59-
return "Error: Invalid file path — the filename must not escape the target directory."
44+
# Confine the resolved write target to an allow-listed root
45+
# (cwd + CREWAI_TOOLS_ALLOWED_DIRS), NOT merely inside the
46+
# caller-supplied `directory`. That value is itself untrusted when
47+
# an LLM tool call chooses it, so checking containment against it
48+
# would let an agent write anywhere (e.g. ~/.ssh/authorized_keys).
49+
# validate_file_path resolves symlinks and ".." before checking.
50+
try:
51+
real_filepath = validate_file_path(filepath)
52+
except ValueError as e:
53+
return f"Error: {format_error_for_display(e)}"
54+
55+
real_directory = os.path.dirname(real_filepath)
56+
display_filepath = format_path_for_display(real_filepath, real_directory)
57+
58+
# A target that resolves to an existing directory is not a valid
59+
# file destination.
60+
if os.path.isdir(real_filepath):
61+
return (
62+
"Error: Invalid file path — the target must be a file, "
63+
"not a directory."
64+
)
6065

6166
if kwargs.get("directory"):
6267
os.makedirs(real_directory, exist_ok=True)

lib/crewai-tools/tests/tools/test_file_writer_tool.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,23 @@ def temp_env():
1717
test_file = "test.txt"
1818
test_content = "Hello, World!"
1919

20+
# FileWriterTool confines writes to an allow-listed root (cwd plus
21+
# CREWAI_TOOLS_ALLOWED_DIRS). Explicitly permit this temp dir — this is the
22+
# supported way for a developer to widen the write scope to an external
23+
# directory, and lets the happy-path tests below write into it.
24+
prev_allowed = os.environ.get("CREWAI_TOOLS_ALLOWED_DIRS")
25+
os.environ["CREWAI_TOOLS_ALLOWED_DIRS"] = temp_dir
26+
2027
yield {
2128
"temp_dir": temp_dir,
2229
"test_file": test_file,
2330
"test_content": test_content,
2431
}
2532

33+
if prev_allowed is None:
34+
os.environ.pop("CREWAI_TOOLS_ALLOWED_DIRS", None)
35+
else:
36+
os.environ["CREWAI_TOOLS_ALLOWED_DIRS"] = prev_allowed
2637
shutil.rmtree(temp_dir, ignore_errors=True)
2738

2839

@@ -196,3 +207,24 @@ def test_blocks_symlink_escape(tool, temp_env):
196207
assert not os.path.exists(outside_file)
197208
finally:
198209
shutil.rmtree(outside_dir, ignore_errors=True)
210+
211+
212+
213+
def test_blocks_unbounded_directory_arg(tool, temp_env):
214+
# The core fix: the `directory` argument is itself untrusted (LLM-chosen).
215+
# A directory outside the allow-list must be rejected even when filename
216+
# is benign — previously this let an agent write anywhere on disk
217+
# (e.g. ~/.ssh/authorized_keys).
218+
outside_dir = tempfile.mkdtemp() # NOT added to CREWAI_TOOLS_ALLOWED_DIRS
219+
outside_file = os.path.join(outside_dir, "test.txt")
220+
try:
221+
result = tool._run(
222+
filename="test.txt",
223+
directory=outside_dir,
224+
content="should not be written",
225+
overwrite=True,
226+
)
227+
assert "Error" in result
228+
assert not os.path.exists(outside_file)
229+
finally:
230+
shutil.rmtree(outside_dir, ignore_errors=True)

lib/crewai-tools/tests/utilities/test_safe_path.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ def test_valid_nested_path(self, tmp_path):
3232

3333
def test_rejects_dotdot_traversal(self, tmp_path):
3434
"""Reject ../ traversal that escapes base_dir."""
35-
with pytest.raises(ValueError, match="outside the allowed directory"):
35+
with pytest.raises(ValueError, match="outside the allowed director"):
3636
validate_file_path("../../etc/passwd", str(tmp_path))
3737

3838
def test_rejects_absolute_path_outside_base(self, tmp_path):
3939
"""Reject absolute path outside base_dir."""
40-
with pytest.raises(ValueError, match="outside the allowed directory"):
40+
with pytest.raises(ValueError, match="outside the allowed director"):
4141
validate_file_path("/etc/passwd", str(tmp_path))
4242

4343
def test_allows_absolute_path_inside_base(self, tmp_path):
@@ -50,7 +50,7 @@ def test_rejects_symlink_escape(self, tmp_path):
5050
"""Reject symlinks that point outside base_dir."""
5151
link = tmp_path / "sneaky_link"
5252
os.symlink("/etc/passwd", str(link))
53-
with pytest.raises(ValueError, match="outside the allowed directory"):
53+
with pytest.raises(ValueError, match="outside the allowed director"):
5454
validate_file_path("sneaky_link", str(tmp_path))
5555

5656
def test_defaults_to_cwd(self):
@@ -113,7 +113,7 @@ def test_rejects_file_as_directory(self, tmp_path):
113113
validate_directory_path("file.txt", str(tmp_path))
114114

115115
def test_rejects_traversal(self, tmp_path):
116-
with pytest.raises(ValueError, match="outside the allowed directory"):
116+
with pytest.raises(ValueError, match="outside the allowed director"):
117117
validate_directory_path("../../", str(tmp_path))
118118

119119

@@ -191,3 +191,62 @@ def test_escape_hatch(self, monkeypatch):
191191
# file:// would normally be blocked
192192
result = validate_url("file:///etc/passwd")
193193
assert result == "file:///etc/passwd"
194+
195+
196+
197+
class TestAllowList:
198+
"""Tests for the configurable deny-by-default allow-list of roots."""
199+
200+
def test_param_extends_allowed_roots(self, tmp_path):
201+
"""A directory passed via allowed_dirs is permitted."""
202+
extra = tmp_path / "extra"
203+
extra.mkdir()
204+
(extra / "data.txt").touch()
205+
result = validate_file_path(
206+
str(extra / "data.txt"),
207+
base_dir=str(tmp_path / "base"),
208+
allowed_dirs=[str(extra)],
209+
)
210+
assert result == str(extra / "data.txt")
211+
212+
def test_env_extends_allowed_roots(self, tmp_path, monkeypatch):
213+
"""A directory listed in CREWAI_TOOLS_ALLOWED_DIRS is permitted."""
214+
base = tmp_path / "base"
215+
base.mkdir()
216+
extra = tmp_path / "extra"
217+
extra.mkdir()
218+
(extra / "data.txt").touch()
219+
monkeypatch.setenv("CREWAI_TOOLS_ALLOWED_DIRS", str(extra))
220+
result = validate_file_path(str(extra / "data.txt"), base_dir=str(base))
221+
assert result == str(extra / "data.txt")
222+
223+
def test_denied_without_allow_listing(self, tmp_path, monkeypatch):
224+
"""The same external dir is rejected when not allow-listed."""
225+
base = tmp_path / "base"
226+
base.mkdir()
227+
extra = tmp_path / "extra"
228+
extra.mkdir()
229+
(extra / "data.txt").touch()
230+
monkeypatch.delenv("CREWAI_TOOLS_ALLOWED_DIRS", raising=False)
231+
with pytest.raises(ValueError, match="outside the allowed director"):
232+
validate_file_path(str(extra / "data.txt"), base_dir=str(base))
233+
234+
def test_multiple_env_roots(self, tmp_path, monkeypatch):
235+
"""Multiple os.pathsep-separated roots are each honored."""
236+
base = tmp_path / "base"
237+
base.mkdir()
238+
a = tmp_path / "a"
239+
a.mkdir()
240+
b = tmp_path / "b"
241+
b.mkdir()
242+
(a / "fa.txt").touch()
243+
(b / "fb.txt").touch()
244+
monkeypatch.setenv(
245+
"CREWAI_TOOLS_ALLOWED_DIRS", os.pathsep.join([str(a), str(b)])
246+
)
247+
assert validate_file_path(str(a / "fa.txt"), base_dir=str(base)) == str(
248+
a / "fa.txt"
249+
)
250+
assert validate_file_path(str(b / "fb.txt"), base_dir=str(base)) == str(
251+
b / "fb.txt"
252+
)

0 commit comments

Comments
 (0)