Files
crewAI/lib/crewai-tools/tests/tools/test_file_writer_tool.py
Rip&Tear 6fc0914f26 fix: add base_dir path containment to FileWriterTool
os.path.join does not prevent traversal — joining "./" with "../../../etc/cron.d/pwned"
resolves cleanly outside any intended scope. The tool also called os.makedirs on
the unvalidated path, meaning it would create arbitrary directory structures.

Adds a base_dir parameter that uses os.path.realpath() to resolve the final path
(including symlinks) before checking containment. Any filename or directory argument
that resolves outside base_dir is rejected before any filesystem operation occurs.

When base_dir is not set the tool behaves as before — only use that in fully
sandboxed environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:51:01 +08:00

213 lines
5.7 KiB
Python

import os
import shutil
import tempfile
from crewai_tools.tools.file_writer_tool.file_writer_tool import FileWriterTool
import pytest
@pytest.fixture
def tool():
return FileWriterTool()
@pytest.fixture
def temp_env():
temp_dir = tempfile.mkdtemp()
test_file = "test.txt"
test_content = "Hello, World!"
yield {
"temp_dir": temp_dir,
"test_file": test_file,
"test_content": test_content,
}
shutil.rmtree(temp_dir, ignore_errors=True)
def get_test_path(filename, directory):
return os.path.join(directory, filename)
def read_file(path):
with open(path, "r") as f:
return f.read()
def test_basic_file_write(tool, temp_env):
result = tool._run(
filename=temp_env["test_file"],
directory=temp_env["temp_dir"],
content=temp_env["test_content"],
overwrite=True,
)
path = get_test_path(temp_env["test_file"], temp_env["temp_dir"])
assert os.path.exists(path)
assert read_file(path) == temp_env["test_content"]
assert "successfully written" in result
def test_directory_creation(tool, temp_env):
new_dir = os.path.join(temp_env["temp_dir"], "nested_dir")
result = tool._run(
filename=temp_env["test_file"],
directory=new_dir,
content=temp_env["test_content"],
overwrite=True,
)
path = get_test_path(temp_env["test_file"], new_dir)
assert os.path.exists(new_dir)
assert os.path.exists(path)
assert "successfully written" in result
@pytest.mark.parametrize(
"overwrite",
["y", "yes", "t", "true", "on", "1", True],
)
def test_overwrite_true(tool, temp_env, overwrite):
path = get_test_path(temp_env["test_file"], temp_env["temp_dir"])
with open(path, "w") as f:
f.write("Original content")
result = tool._run(
filename=temp_env["test_file"],
directory=temp_env["temp_dir"],
content="New content",
overwrite=overwrite,
)
assert read_file(path) == "New content"
assert "successfully written" in result
def test_invalid_overwrite_value(tool, temp_env):
result = tool._run(
filename=temp_env["test_file"],
directory=temp_env["temp_dir"],
content=temp_env["test_content"],
overwrite="invalid",
)
assert "invalid value" in result
def test_missing_required_fields(tool, temp_env):
result = tool._run(
directory=temp_env["temp_dir"],
content=temp_env["test_content"],
overwrite=True,
)
assert "An error occurred while accessing key: 'filename'" in result
def test_empty_content(tool, temp_env):
result = tool._run(
filename=temp_env["test_file"],
directory=temp_env["temp_dir"],
content="",
overwrite=True,
)
path = get_test_path(temp_env["test_file"], temp_env["temp_dir"])
assert os.path.exists(path)
assert read_file(path) == ""
assert "successfully written" in result
@pytest.mark.parametrize(
"overwrite",
["n", "no", "f", "false", "off", "0", False],
)
def test_file_exists_error_handling(tool, temp_env, overwrite):
path = get_test_path(temp_env["test_file"], temp_env["temp_dir"])
with open(path, "w") as f:
f.write("Pre-existing content")
result = tool._run(
filename=temp_env["test_file"],
directory=temp_env["temp_dir"],
content="Should not be written",
overwrite=overwrite,
)
assert "already exists and overwrite option was not passed" in result
assert read_file(path) == "Pre-existing content"
# --- base_dir containment ---
@pytest.fixture
def scoped_tool(temp_env):
return FileWriterTool(base_dir=temp_env["temp_dir"])
def test_base_dir_allows_write_inside(scoped_tool, temp_env):
result = scoped_tool._run(
filename=temp_env["test_file"],
directory=temp_env["temp_dir"],
content=temp_env["test_content"],
overwrite=True,
)
assert "successfully written" in result
assert read_file(get_test_path(temp_env["test_file"], temp_env["temp_dir"])) == temp_env["test_content"]
def test_base_dir_blocks_traversal_in_filename(scoped_tool, temp_env):
result = scoped_tool._run(
filename="../outside.txt",
directory=temp_env["temp_dir"],
content="should not be written",
overwrite=True,
)
assert "Access denied" in result
def test_base_dir_blocks_traversal_in_directory(scoped_tool, temp_env):
result = scoped_tool._run(
filename="pwned.txt",
directory=os.path.join(temp_env["temp_dir"], "../../etc/cron.d"),
content="should not be written",
overwrite=True,
)
assert "Access denied" in result
def test_base_dir_blocks_absolute_path_outside(scoped_tool, temp_env):
result = scoped_tool._run(
filename="passwd",
directory="/etc",
content="should not be written",
overwrite=True,
)
assert "Access denied" in result
def test_base_dir_blocks_symlink_escape(scoped_tool, temp_env):
link = os.path.join(temp_env["temp_dir"], "escape")
os.symlink("/etc", link)
result = scoped_tool._run(
filename="crontab",
directory=link,
content="should not be written",
overwrite=True,
)
assert "Access denied" in result
def test_base_dir_allows_nested_subdir(scoped_tool, temp_env):
result = scoped_tool._run(
filename="file.txt",
directory=os.path.join(temp_env["temp_dir"], "subdir"),
content="nested content",
overwrite=True,
)
assert "successfully written" in result
def test_base_dir_description_mentions_directory(temp_env):
tool = FileWriterTool(base_dir=temp_env["temp_dir"])
assert temp_env["temp_dir"] in tool.description