Compare commits

..

4 Commits

Author SHA1 Message Date
Rip&Tear
96383abd8b fix: prevent path traversal in FileWriterTool without interface changes
Adds containment check inside _run() using os.path.realpath() to ensure
the resolved file path stays within the resolved directory. Blocks ../
sequences, absolute filenames, and symlink escapes transparently —
no schema or interface changes required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:16:13 +08:00
Rip&Tear
61d02d7296 fix: remove directory field from LLM schema when base_dir is set
When a developer sets base_dir, they control where files are written.
The LLM should only supply filename and content — not a directory path.

Adds ScopedFileWriterToolInput (no directory field) which is used when
base_dir is provided at construction, following the same pattern as
FileReadTool/ScrapeWebsiteTool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:05:37 +08:00
Rip&Tear
438dee3783 fix: make directory relative to base_dir for better UX
When base_dir is set, the directory arg is now treated as a subdirectory
of base_dir rather than an absolute path. This means the LLM only needs
to specify a filename (and optionally a relative subdirectory) — it does
not need to repeat the base_dir path.

  FileWriterTool(base_dir="./output")
  → filename="report.txt"            writes to ./output/report.txt
  → filename="f.txt", directory="sub" writes to ./output/sub/f.txt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:03:53 +08:00
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
2 changed files with 54 additions and 12 deletions

View File

@@ -30,27 +30,34 @@ class FileWriterTool(BaseTool):
def _run(self, **kwargs: Any) -> str:
try:
directory = kwargs.get("directory") or "./"
filename = kwargs["filename"]
filepath = os.path.join(directory, filename)
# Prevent path traversal: the resolved path must stay within the
# resolved directory. This blocks ../sequences, absolute paths in
# filename, and symlink escapes regardless of how directory is set.
real_directory = os.path.realpath(directory)
real_filepath = os.path.realpath(filepath)
if not real_filepath.startswith(real_directory + os.sep) and real_filepath != real_directory:
return "Error: Invalid file path — the filename must not escape the target directory."
if kwargs.get("directory"):
os.makedirs(kwargs["directory"], exist_ok=True)
os.makedirs(real_directory, exist_ok=True)
# Construct the full path
filepath = os.path.join(kwargs.get("directory") or "", kwargs["filename"])
# Convert overwrite to boolean
kwargs["overwrite"] = strtobool(kwargs["overwrite"])
# Check if file exists and overwrite is not allowed
if os.path.exists(filepath) and not kwargs["overwrite"]:
return f"File {filepath} already exists and overwrite option was not passed."
if os.path.exists(real_filepath) and not kwargs["overwrite"]:
return f"File {real_filepath} already exists and overwrite option was not passed."
# Write content to the file
mode = "w" if kwargs["overwrite"] else "x"
with open(filepath, mode) as file:
with open(real_filepath, mode) as file:
file.write(kwargs["content"])
return f"Content successfully written to {filepath}"
return f"Content successfully written to {real_filepath}"
except FileExistsError:
return (
f"File {filepath} already exists and overwrite option was not passed."
f"File {real_filepath} already exists and overwrite option was not passed."
)
except KeyError as e:
return f"An error occurred while accessing key: {e!s}"

View File

@@ -135,3 +135,38 @@ def test_file_exists_error_handling(tool, temp_env, overwrite):
assert "already exists and overwrite option was not passed" in result
assert read_file(path) == "Pre-existing content"
# --- Path traversal prevention ---
def test_blocks_traversal_in_filename(tool, temp_env):
result = tool._run(
filename="../outside.txt",
directory=temp_env["temp_dir"],
content="should not be written",
overwrite=True,
)
assert "Error" in result
assert not os.path.exists(os.path.join(temp_env["temp_dir"], "../outside.txt"))
def test_blocks_absolute_path_in_filename(tool, temp_env):
result = tool._run(
filename="/etc/passwd",
directory=temp_env["temp_dir"],
content="should not be written",
overwrite=True,
)
assert "Error" in result
def test_blocks_symlink_escape(tool, temp_env):
link = os.path.join(temp_env["temp_dir"], "escape")
os.symlink("/etc", link)
result = tool._run(
filename="escape/crontab",
directory=temp_env["temp_dir"],
content="should not be written",
overwrite=True,
)
assert "Error" in result