Repository: langchain-ai/deepagents (python)

Issue:
`edit_file` crashes

```
================================= Tool Message =================================
Name: write_file

Cannot write to /question_request.md because it already exists. Read and then make an edit, or write to a new path.
================================== Ai Message ==================================
Tool Calls:
  edit_file (2bf543b7-c156-43ee-9f95-f06b25f65244)
 Call ID: 2bf543b7-c156-43ee-9f95-f06b25f65244
  Args:
    content: === 2026-01-13 15:27:10.954390 ===
What is task decomposition?
What is the standard method for Task Decomposition?
Once you get the answer, look up common extensions of that method.
=== Fri Jan 13 15:27:10 +00 2026 ===
What is task decomposition?
What is the standard method for Task Decomposition?
Once you get the answer, look up common extensions of that method.
=== Sat Jan 14 10:00:00 +00 2026 ===
What is task decomposition?
What is the standard method for Task Decomposition?
Once you get the answer, look up common extensions of that method.
=== Sun Jan 15 09:30:00 +00 2026 ===
What is task decomposition?
What is the standard method for Task Decomposition?
Once you get the answer, look up common extensions of that method.

    file_path: /question_request.md
    status: replace
================================= Tool Message =================================
Name: edit_file

Error invoking tool 'edit_file' with kwargs {'content': '=== 2026-01-13 15:27:10.954390 ===\nWhat is task decomposition?\nWhat is the standard method for Task Decomposition?\nOnce you get the answer, look up common extensions of that method.\n=== Fri Jan 13 15:27:10 +00 2026 ===\nWhat is task decomposition?\nWhat is the standard method for Task Decomposition?\nOnce you get the answer, look up common extensions of that method.\n=== Sat Jan 14 10:00:00 +00 2026 ===\nWhat is task decomposition?\nWhat is the standard method for Task Decomposition?\nOnce you get the answer, look up common extensions of that method.\n=== Sun Jan 15 09:30:00 +00 2026 ===\nWhat is task decomposition?\nWhat is the standard method for Task Decomposition?\nOnce you get the answer, look up common extensions of that method.\n', 'file_path': '/question_request.md', 'status': 'replace'} with error:
 old_string: Field required
new_string: Field required
 Please fix the error and try again.
================================== Ai Message ==================================
```


Patch:
diff --git a/libs/deepagents/deepagents/middleware/filesystem.py b/libs/deepagents/deepagents/middleware/filesystem.py
index b2c5310178..23ab0d7058 100644
--- a/libs/deepagents/deepagents/middleware/filesystem.py
+++ b/libs/deepagents/deepagents/middleware/filesystem.py
@@ -493,7 +493,10 @@ def sync_ls(
         ) -> str:
             """Synchronous wrapper for ls tool."""
             resolved_backend = self._get_backend(runtime)
-            validated_path = _validate_path(path)
+            try:
+                validated_path = _validate_path(path)
+            except ValueError as e:
+                return f"Error: {e}"
             infos = resolved_backend.ls_info(validated_path)
             paths = [fi.get("path", "") for fi in infos]
             result = truncate_if_too_long(paths)
@@ -505,7 +508,10 @@ async def async_ls(
         ) -> str:
             """Asynchronous wrapper for ls tool."""
             resolved_backend = self._get_backend(runtime)
-            validated_path = _validate_path(path)
+            try:
+                validated_path = _validate_path(path)
+            except ValueError as e:
+                return f"Error: {e}"
             infos = await resolved_backend.als_info(validated_path)
             paths = [fi.get("path", "") for fi in infos]
             result = truncate_if_too_long(paths)
@@ -531,7 +537,10 @@ def sync_read_file(
         ) -> str:
             """Synchronous wrapper for read_file tool."""
             resolved_backend = self._get_backend(runtime)
-            validated_path = _validate_path(file_path)
+            try:
+                validated_path = _validate_path(file_path)
+            except ValueError as e:
+                return f"Error: {e}"
             result = resolved_backend.read(validated_path, offset=offset, limit=limit)
 
             lines = result.splitlines(keepends=True)
@@ -557,7 +566,10 @@ async def async_read_file(
         ) -> str:
             """Asynchronous wrapper for read_file tool."""
             resolved_backend = self._get_backend(runtime)
-            validated_path = _validate_path(file_path)
+            try:
+                validated_path = _validate_path(file_path)
+            except ValueError as e:
+                return f"Error: {e}"
             result = await resolved_backend.aread(validated_path, offset=offset, limit=limit)
 
             lines = result.splitlines(keepends=True)
@@ -593,7 +605,10 @@ def sync_write_file(
         ) -> Command | str:
             """Synchronous wrapper for write_file tool."""
             resolved_backend = self._get_backend(runtime)
-            validated_path = _validate_path(file_path)
+            try:
+                validated_path = _validate_path(file_path)
+            except ValueError as e:
+                return f"Error: {e}"
             res: WriteResult = resolved_backend.write(validated_path, content)
             if res.error:
                 return res.error
@@ -619,7 +634,10 @@ async def async_write_file(
         ) -> Command | str:
             """Asynchronous wrapper for write_file tool."""
             resolved_backend = self._get_backend(runtime)
-            validated_path = _validate_path(file_path)
+            try:
+                validated_path = _validate_path(file_path)
+            except ValueError as e:
+                return f"Error: {e}"
             res: WriteResult = await resolved_backend.awrite(validated_path, content)
             if res.error:
                 return res.error
@@ -659,7 +677,10 @@ def sync_edit_file(
         ) -> Command | str:
             """Synchronous wrapper for edit_file tool."""
             resolved_backend = self._get_backend(runtime)
-            validated_path = _validate_path(file_path)
+            try:
+                validated_path = _validate_path(file_path)
+            except ValueError as e:
+                return f"Error: {e}"
             res: EditResult = resolved_backend.edit(validated_path, old_string, new_string, replace_all=replace_all)
             if res.error:
                 return res.error
@@ -687,7 +708,10 @@ async def async_edit_file(
         ) -> Command | str:
             """Asynchronous wrapper for edit_file tool."""
             resolved_backend = self._get_backend(runtime)
-            validated_path = _validate_path(file_path)
+            try:
+                validated_path = _validate_path(file_path)
+            except ValueError as e:
+                return f"Error: {e}"
             res: EditResult = await resolved_backend.aedit(validated_path, old_string, new_string, replace_all=replace_all)
             if res.error:
                 return res.error


Test patch:
diff --git a/libs/deepagents/tests/unit_tests/test_file_system_tools.py b/libs/deepagents/tests/unit_tests/test_file_system_tools.py
index 47e16f83be..4b6508cc7d 100644
--- a/libs/deepagents/tests/unit_tests/test_file_system_tools.py
+++ b/libs/deepagents/tests/unit_tests/test_file_system_tools.py
@@ -413,3 +413,173 @@ def test_parallel_edit_file_calls() -> None:
         config={"configurable": {"thread_id": "test_thread_parallel_edits"}},
     )
     assert False, "Finish implementing correct behavior to add a ToolMessage with error if parallel edits to the same file are attempted."  # noqa: PT015, B011
+
+
+def test_path_traversal_returns_error_message() -> None:
+    """Verify that path traversal attempts return error messages instead of crashing."""
+    fake_model = GenericFakeChatModel(
+        messages=iter(
+            [
+                AIMessage(
+                    content="",
+                    tool_calls=[
+                        {
+                            "name": "edit_file",
+                            "args": {
+                                "file_path": "./question/..",
+                                "old_string": "test",
+                                "new_string": "replaced",
+                            },
+                            "id": "call_path_traversal",
+                            "type": "tool_call",
+                        },
+                    ],
+                ),
+                AIMessage(content="I see there was an error with the path."),
+            ]
+        )
+    )
+
+    agent = create_deep_agent(
+        model=fake_model,
+        checkpointer=InMemorySaver(),
+    )
+
+    # This should NOT raise an exception - it should return an error message
+    result = agent.invoke(
+        {"messages": [HumanMessage(content="Edit a file with bad path")]},
+        config={"configurable": {"thread_id": "test_path_traversal"}},
+    )
+
+    # Find the ToolMessage in the result
+    tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
+    assert len(tool_messages) >= 1, "Expected at least one ToolMessage"
+
+    # The tool message should contain an error about path traversal
+    error_message = tool_messages[0].content
+    assert error_message == "Error: Path traversal not allowed: ./question/.."
+
+
+def test_windows_absolute_path_returns_error_message() -> None:
+    """Verify that Windows absolute paths return error messages instead of crashing."""
+    fake_model = GenericFakeChatModel(
+        messages=iter(
+            [
+                AIMessage(
+                    content="",
+                    tool_calls=[
+                        {
+                            "name": "read_file",
+                            "args": {
+                                "file_path": "C:\\Users\\test\\file.txt",
+                            },
+                            "id": "call_windows_path",
+                            "type": "tool_call",
+                        },
+                    ],
+                ),
+                AIMessage(content="I see there was an error with the path."),
+            ]
+        )
+    )
+
+    agent = create_deep_agent(
+        model=fake_model,
+        checkpointer=InMemorySaver(),
+    )
+
+    result = agent.invoke(
+        {"messages": [HumanMessage(content="Read a file with Windows path")]},
+        config={"configurable": {"thread_id": "test_windows_path"}},
+    )
+
+    tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
+    assert len(tool_messages) >= 1, "Expected at least one ToolMessage"
+
+    error_message = tool_messages[0].content
+    expected_error = (
+        "Error: Windows absolute paths are not supported: C:\\Users\\test\\file.txt. "
+        "Please use virtual paths starting with / (e.g., /workspace/file.txt)"
+    )
+    assert error_message == expected_error
+
+
+def test_tilde_path_returns_error_message() -> None:
+    """Verify that tilde paths return error messages instead of crashing."""
+    fake_model = GenericFakeChatModel(
+        messages=iter(
+            [
+                AIMessage(
+                    content="",
+                    tool_calls=[
+                        {
+                            "name": "write_file",
+                            "args": {
+                                "file_path": "~/secret.txt",
+                                "content": "secret data",
+                            },
+                            "id": "call_tilde_path",
+                            "type": "tool_call",
+                        },
+                    ],
+                ),
+                AIMessage(content="I see there was an error with the path."),
+            ]
+        )
+    )
+
+    agent = create_deep_agent(
+        model=fake_model,
+        checkpointer=InMemorySaver(),
+    )
+
+    result = agent.invoke(
+        {"messages": [HumanMessage(content="Write a file with tilde path")]},
+        config={"configurable": {"thread_id": "test_tilde_path"}},
+    )
+
+    tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
+    assert len(tool_messages) >= 1, "Expected at least one ToolMessage"
+
+    error_message = tool_messages[0].content
+    assert error_message == "Error: Path traversal not allowed: ~/secret.txt"
+
+
+def test_ls_with_invalid_path_returns_error_message() -> None:
+    """Verify that ls tool with invalid path returns error message instead of crashing."""
+    fake_model = GenericFakeChatModel(
+        messages=iter(
+            [
+                AIMessage(
+                    content="",
+                    tool_calls=[
+                        {
+                            "name": "ls",
+                            "args": {
+                                "path": "../../../etc",
+                            },
+                            "id": "call_ls_invalid",
+                            "type": "tool_call",
+                        },
+                    ],
+                ),
+                AIMessage(content="I see there was an error with the path."),
+            ]
+        )
+    )
+
+    agent = create_deep_agent(
+        model=fake_model,
+        checkpointer=InMemorySaver(),
+    )
+
+    result = agent.invoke(
+        {"messages": [HumanMessage(content="List directory with invalid path")]},
+        config={"configurable": {"thread_id": "test_ls_invalid_path"}},
+    )
+
+    tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
+    assert len(tool_messages) >= 1, "Expected at least one ToolMessage"
+
+    error_message = tool_messages[0].content
+    assert error_message == "Error: Path traversal not allowed: ../../../etc"
diff --git a/libs/deepagents/tests/unit_tests/test_file_system_tools_async.py b/libs/deepagents/tests/unit_tests/test_file_system_tools_async.py
new file mode 100644
index 0000000000..7c48f9204f
--- /dev/null
+++ b/libs/deepagents/tests/unit_tests/test_file_system_tools_async.py
@@ -0,0 +1,180 @@
+"""Async unit tests for file system tools path validation.
+
+This module contains async versions of the path validation error handling tests.
+"""
+
+from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
+from langgraph.checkpoint.memory import InMemorySaver
+
+from deepagents.graph import create_deep_agent
+from tests.unit_tests.chat_model import GenericFakeChatModel
+
+
+async def test_path_traversal_returns_error_message_async() -> None:
+    """Verify that path traversal attempts return error messages instead of crashing."""
+    fake_model = GenericFakeChatModel(
+        messages=iter(
+            [
+                AIMessage(
+                    content="",
+                    tool_calls=[
+                        {
+                            "name": "edit_file",
+                            "args": {
+                                "file_path": "./question/..",
+                                "old_string": "test",
+                                "new_string": "replaced",
+                            },
+                            "id": "call_path_traversal",
+                            "type": "tool_call",
+                        },
+                    ],
+                ),
+                AIMessage(content="I see there was an error with the path."),
+            ]
+        )
+    )
+
+    agent = create_deep_agent(
+        model=fake_model,
+        checkpointer=InMemorySaver(),
+    )
+
+    # This should NOT raise an exception - it should return an error message
+    result = await agent.ainvoke(
+        {"messages": [HumanMessage(content="Edit a file with bad path")]},
+        config={"configurable": {"thread_id": "test_path_traversal_async"}},
+    )
+
+    # Find the ToolMessage in the result
+    tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
+    assert len(tool_messages) >= 1, "Expected at least one ToolMessage"
+
+    # The tool message should contain an error about path traversal
+    error_message = tool_messages[0].content
+    assert error_message == "Error: Path traversal not allowed: ./question/.."
+
+
+async def test_windows_absolute_path_returns_error_message_async() -> None:
+    """Verify that Windows absolute paths return error messages instead of crashing."""
+    fake_model = GenericFakeChatModel(
+        messages=iter(
+            [
+                AIMessage(
+                    content="",
+                    tool_calls=[
+                        {
+                            "name": "read_file",
+                            "args": {
+                                "file_path": "C:\\Users\\test\\file.txt",
+                            },
+                            "id": "call_windows_path",
+                            "type": "tool_call",
+                        },
+                    ],
+                ),
+                AIMessage(content="I see there was an error with the path."),
+            ]
+        )
+    )
+
+    agent = create_deep_agent(
+        model=fake_model,
+        checkpointer=InMemorySaver(),
+    )
+
+    result = await agent.ainvoke(
+        {"messages": [HumanMessage(content="Read a file with Windows path")]},
+        config={"configurable": {"thread_id": "test_windows_path_async"}},
+    )
+
+    tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
+    assert len(tool_messages) >= 1, "Expected at least one ToolMessage"
+
+    error_message = tool_messages[0].content
+    expected_error = (
+        "Error: Windows absolute paths are not supported: C:\\Users\\test\\file.txt. "
+        "Please use virtual paths starting with / (e.g., /workspace/file.txt)"
+    )
+    assert error_message == expected_error
+
+
+async def test_tilde_path_returns_error_message_async() -> None:
+    """Verify that tilde paths return error messages instead of crashing."""
+    fake_model = GenericFakeChatModel(
+        messages=iter(
+            [
+                AIMessage(
+                    content="",
+                    tool_calls=[
+                        {
+                            "name": "write_file",
+                            "args": {
+                                "file_path": "~/secret.txt",
+                                "content": "secret data",
+                            },
+                            "id": "call_tilde_path",
+                            "type": "tool_call",
+                        },
+                    ],
+                ),
+                AIMessage(content="I see there was an error with the path."),
+            ]
+        )
+    )
+
+    agent = create_deep_agent(
+        model=fake_model,
+        checkpointer=InMemorySaver(),
+    )
+
+    result = await agent.ainvoke(
+        {"messages": [HumanMessage(content="Write a file with tilde path")]},
+        config={"configurable": {"thread_id": "test_tilde_path_async"}},
+    )
+
+    tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
+    assert len(tool_messages) >= 1, "Expected at least one ToolMessage"
+
+    error_message = tool_messages[0].content
+    assert error_message == "Error: Path traversal not allowed: ~/secret.txt"
+
+
+async def test_ls_with_invalid_path_returns_error_message_async() -> None:
+    """Verify that ls tool with invalid path returns error message instead of crashing."""
+    fake_model = GenericFakeChatModel(
+        messages=iter(
+            [
+                AIMessage(
+                    content="",
+                    tool_calls=[
+                        {
+                            "name": "ls",
+                            "args": {
+                                "path": "../../../etc",
+                            },
+                            "id": "call_ls_invalid",
+                            "type": "tool_call",
+                        },
+                    ],
+                ),
+                AIMessage(content="I see there was an error with the path."),
+            ]
+        )
+    )
+
+    agent = create_deep_agent(
+        model=fake_model,
+        checkpointer=InMemorySaver(),
+    )
+
+    result = await agent.ainvoke(
+        {"messages": [HumanMessage(content="List directory with invalid path")]},
+        config={"configurable": {"thread_id": "test_ls_invalid_path_async"}},
+    )
+
+    tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
+    assert len(tool_messages) >= 1, "Expected at least one ToolMessage"
+
+    error_message = tool_messages[0].content
+    assert error_message == "Error: Path traversal not allowed: ../../../etc"