mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-29 02:29:31 +00:00
Some checks failed
* fix(tools): sandbox FileWriterTool writes and fix file tool rough edges FileReadTool confined reads to the working directory, but FileWriterTool only checked that `filename` stayed inside `directory` — and `directory` itself is an LLM-supplied schema field. An agent could therefore write anywhere the process had permission to, including ~/.ssh and site-packages, while the reader refused to read back what the writer had just written. FileWriterTool was the only filesystem tool in the package that did not go through validate_file_path; files_compressor_tool validates even its output path. Writes are now confined to base_dir (the working directory by default): the resolved directory must sit inside base_dir, and the resolved file must sit inside that directory. The pre-existing filename containment check is kept as-is and still applies even when the unsafe-paths escape hatch is on, so no existing guarantee is weakened. Both tools gain a base_dir field so a developer can widen the sandbox deliberately instead of reaching for the process-wide CREWAI_TOOLS_ALLOW_UNSAFE_PATHS kill switch. FileReadTool also stops rejecting a file_path given to its own constructor: that is developer-declared intent, and declaring one file does not expose its siblings. Also fixed: - FileReadTool scanned the whole file when reading a line window; it now stops via islice once the requested lines are collected. - FileWriterTool._run(**kwargs) made the documented positional call signature raise TypeError and turned a missing overwrite into "error accessing key". It now takes named parameters in the documented (filename, content, directory) order. - A directory naming an existing file reported "already exists and overwrite option was not passed" even with overwrite=True; it now explains the real problem. - Subdirectories inside filename are created, matching what passing directory already did. - Both tools now write and decode UTF-8 by default instead of the platform locale encoding, with an encoding field to override. The docs already claimed UTF-8 and recommended the writer to Windows users. - The writer's schema fields had no descriptions for the LLM. - Docs claimed FileReadTool parses JSON into a dict (it never has), shipped a snippet that raised TypeError, and did not mention the path sandbox. The writer README also began with a stray "Here's the rewritten README" preamble. BREAKING CHANGE: FileWriterTool no longer writes outside the working directory. Pass base_dir to authorize a different tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: update tool specifications * fix(tools): make the declared FileReadTool file reachable by agents Addresses review feedback on #6692. The constructor-path exemption did not actually work the way an agent calls the tool. The description only advertises a redacted label (the basename, when the file sits outside the sandbox), but resolution required the exact absolute path, so the model's call was sandboxed and the declared file was never read. Worse, file_path was a required schema field, so the long-documented "call with no arguments to read the default file" raised a validation error instead: FileReadTool(file_path="/outside/declared.txt") .run() -> ValueError: validation failed .run(file_path="declared.txt") -> Error: File not found .run(file_path="/outside/declared.txt") -> works, but the model was never told this path file_path is now optional in the schema, so omitting it reads the default, and the declared file is addressable by the label the description shows the model as well as by its real path. Declaring one file still does not expose its siblings. The declared path is also pinned to its real path at construction, so a later chdir cannot silently repoint it at a different file — previously a relative constructor path re-resolved against the new working directory on every call. Also guards the writer's filepath resolution, which could raise ValueError out of _run for a filename containing a null byte, breaking the contract of always returning a descriptive string. The directory and read paths were already guarded. Adds docstrings to strtobool and both _run methods, corrects an Arabic tanween spelling and a kaf-as-descriptor calque in the localized read docs, and regenerates tool.specs.json for the schema change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tools): anchor the declared read path to base_dir, not the cwd Addresses the second round of review feedback on #6692. The previous commit pinned a relative constructor file_path with os.path.realpath, which anchors to the working directory, while both format_path_for_display and validate_file_path anchor a relative path to base_dir. With the two roots disagreeing, the same relative string meant two different files — and the tool served the cwd one under a label that looks like it belongs to the sandbox: FileReadTool(file_path="data.txt", base_dir="/allowed") # cwd=/work label advertised to the model -> "data.txt" run(file_path="data.txt") -> contents of /work/data.txt That reads a file from outside base_dir, so it was a sandbox escape introduced by the exemption itself, not just a wrong-file bug. Resolution now goes through a single _resolve_against_base helper that anchors relative paths exactly the way the sandbox does, so the pinned path, the advertised label and the containment check all agree. Covered by test_relative_declared_path_anchors_to_base_dir. Also softens "always readable" to "always allowed past the containment check" in the docstring, README and docs, since bypassing containment does not guarantee the read succeeds — it can still fail on a missing file, a directory, or permissions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tools): tell the LLM about the path sandbox in tool descriptions Addresses the low-confidence notes from the Copilot review on #6692. Both tools' descriptions were pre-sandbox wording, so the model learned about containment only by attempting a path and reading the error back. Both now state that access is confined to the tool's allowed directory and that a path resolving outside it is rejected. The wording deliberately says "the tool's allowed directory" rather than "the working directory", because the root is base_dir when one is set, and naming the absolute root would leak it into the prompt — the same reason paths are redacted in errors. Not changed: the notes also suggested advertising `encoding`. That is a constructor-only field the model cannot set, so describing it to the LLM would be misleading. Also fixes a test docstring that contradicted its own assertion — the public run() path does raise on schema validation failure, which is what the test asserts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tools): anchor base_dir at construction so the sandbox cannot move Addresses the third review round on #6692. Both remaining findings came from the same habit: storing an unanchored string and re-resolving it later. A relative base_dir was kept verbatim and re-resolved against getcwd() on every call, while the declared file was pinned once at construction. After a chdir the sandbox root moved but the declared default did not, so one tool applied two different roots. base_dir is now resolved once — in the reader's __init__, and via a field_validator on the writer so it also applies on the model_validate path. That also covers the serialization concern. model_dump drops the private pin, and __init__ re-runs on restore, so a relative file_path was re-anchored against whatever the working directory happened to be at load time. With base_dir anchored, restore rebuilds the identical pin. The residual case is a relative file_path with no base_dir, where the sandbox root is the working directory too — so both move together and the tool stays self-consistent. Covered by test_declared_path_survives_a_serialization_round_trip and test_relative_base_dir_is_anchored_at_construction on both tools. Also corrects the writer's 'directory' description, README and docs: the default resolves inside the tool's allowed directory, which is base_dir when one is set, not always the working directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
448 lines
15 KiB
Python
448 lines
15 KiB
Python
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"
|
|
|
|
tool = FileReadTool(file_path=test_file)
|
|
assert tool.file_path == test_file
|
|
assert "test_file.txt" in tool.description
|
|
|
|
|
|
def test_file_read_tool_run():
|
|
"""Test FileReadTool _run method with file_path at runtime."""
|
|
test_file = "test_file.txt"
|
|
test_content = "Hello, World!"
|
|
|
|
# Use mock_open to mock file operations
|
|
with patch("builtins.open", mock_open(read_data=test_content)):
|
|
tool = FileReadTool()
|
|
result = tool._run(file_path=test_file)
|
|
assert result == test_content
|
|
|
|
|
|
def test_file_read_tool_error_handling():
|
|
"""Test FileReadTool error handling."""
|
|
tool = FileReadTool()
|
|
result = tool._run()
|
|
assert "Error: No file path provided" in result
|
|
|
|
result = tool._run(file_path="nonexistent/file.txt")
|
|
assert "Error: File not found at path:" in result
|
|
|
|
with patch("builtins.open", side_effect=PermissionError()):
|
|
result = tool._run(file_path="no_permission.txt")
|
|
assert "Error: Permission denied" in result
|
|
|
|
|
|
def test_file_read_tool_constructor_and_run():
|
|
"""Test FileReadTool using both constructor and runtime file paths."""
|
|
test_file1 = "test1.txt"
|
|
test_file2 = "test2.txt"
|
|
content1 = "File 1 content"
|
|
content2 = "File 2 content"
|
|
|
|
with patch("builtins.open", mock_open(read_data=content1)):
|
|
tool = FileReadTool(file_path=test_file1)
|
|
result = tool._run()
|
|
assert result == content1
|
|
|
|
# Then test with content2 (should override constructor file_path)
|
|
with patch("builtins.open", mock_open(read_data=content2)):
|
|
result = tool._run(file_path=test_file2)
|
|
assert result == content2
|
|
|
|
|
|
def test_file_read_tool_chunk_reading():
|
|
"""Test FileReadTool reading specific chunks of a file."""
|
|
test_file = "multiline_test.txt"
|
|
lines = [
|
|
"Line 1\n",
|
|
"Line 2\n",
|
|
"Line 3\n",
|
|
"Line 4\n",
|
|
"Line 5\n",
|
|
"Line 6\n",
|
|
"Line 7\n",
|
|
"Line 8\n",
|
|
"Line 9\n",
|
|
"Line 10\n",
|
|
]
|
|
file_content = "".join(lines)
|
|
|
|
with patch("builtins.open", mock_open(read_data=file_content)):
|
|
tool = FileReadTool()
|
|
|
|
result = tool._run(file_path=test_file, start_line=3, line_count=3)
|
|
expected = "".join(lines[2:5]) # Lines are 0-indexed in the array
|
|
assert result == expected
|
|
|
|
# Test reading from a specific line to the end
|
|
result = tool._run(file_path=test_file, start_line=8)
|
|
expected = "".join(lines[7:])
|
|
assert result == expected
|
|
|
|
# Test with default values (should read entire file)
|
|
result = tool._run(file_path=test_file)
|
|
expected = "".join(lines)
|
|
assert result == expected
|
|
|
|
# Test when start_line is 1 but line_count is specified
|
|
result = tool._run(file_path=test_file, start_line=1, line_count=5)
|
|
expected = "".join(lines[0:5])
|
|
assert result == expected
|
|
|
|
|
|
def test_file_read_tool_chunk_error_handling():
|
|
"""Test error handling for chunk reading."""
|
|
test_file = "short_test.txt"
|
|
lines = ["Line 1\n", "Line 2\n", "Line 3\n"]
|
|
file_content = "".join(lines)
|
|
|
|
with patch("builtins.open", mock_open(read_data=file_content)):
|
|
tool = FileReadTool()
|
|
|
|
result = tool._run(file_path=test_file, start_line=10)
|
|
assert "Error: Start line 10 exceeds the number of lines in the file" in result
|
|
|
|
# Test reading partial chunk when line_count exceeds available lines
|
|
result = tool._run(file_path=test_file, start_line=2, line_count=10)
|
|
expected = "".join(lines[1:]) # Should return from line 2 to end
|
|
assert result == expected
|
|
|
|
|
|
def test_file_read_tool_zero_or_negative_start_line():
|
|
"""Test that start_line values of 0 or negative read from the start of the file."""
|
|
test_file = "negative_test.txt"
|
|
lines = ["Line 1\n", "Line 2\n", "Line 3\n", "Line 4\n", "Line 5\n"]
|
|
file_content = "".join(lines)
|
|
|
|
with patch("builtins.open", mock_open(read_data=file_content)):
|
|
tool = FileReadTool()
|
|
|
|
result = tool._run(file_path=test_file, start_line=None)
|
|
expected = "".join(lines) # Should read the entire file
|
|
assert result == expected
|
|
|
|
result = tool._run(file_path=test_file, start_line=0)
|
|
expected = "".join(lines) # Should read the entire file
|
|
assert result == expected
|
|
|
|
# Test with start_line = 0 and limited line count
|
|
result = tool._run(file_path=test_file, start_line=0, line_count=3)
|
|
expected = "".join(lines[0:3]) # Should read first 3 lines
|
|
assert result == expected
|
|
|
|
result = tool._run(file_path=test_file, start_line=-5)
|
|
expected = "".join(lines) # Should read the entire file
|
|
assert result == expected
|
|
|
|
# Test with negative start_line and limited line count
|
|
result = tool._run(file_path=test_file, start_line=-10, line_count=2)
|
|
expected = "".join(lines[0:2]) # Should read first 2 lines
|
|
assert result == expected
|
|
|
|
|
|
def test_file_read_tool_error_messages_do_not_disclose_absolute_paths(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""FileReadTool should redact absolute prefixes from user-visible errors."""
|
|
monkeypatch.chdir(tmp_path)
|
|
tool = FileReadTool()
|
|
target = tmp_path / "secret.txt"
|
|
|
|
result = tool._run(file_path=str(target))
|
|
assert "secret.txt" in result
|
|
assert str(tmp_path) not in result
|
|
|
|
target.touch()
|
|
with patch("builtins.open", side_effect=PermissionError()):
|
|
result = tool._run(file_path=str(target))
|
|
assert "secret.txt" in result
|
|
assert str(tmp_path) not in result
|
|
|
|
with patch(
|
|
"builtins.open",
|
|
side_effect=OSError(5, "Input/output error", str(target)),
|
|
):
|
|
result = tool._run(file_path=str(target))
|
|
assert "secret.txt" in result
|
|
assert str(tmp_path) not in result
|
|
|
|
|
|
def test_file_read_tool_invalid_path_error_does_not_disclose_workspace(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Validation errors should not echo the resolved workspace path."""
|
|
monkeypatch.chdir(tmp_path)
|
|
outside = tmp_path.parent / "outside.txt"
|
|
|
|
result = FileReadTool()._run(file_path=str(outside))
|
|
|
|
assert "Invalid file path" in result
|
|
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
|