Virtual path resolution errors escape the filesystem tool boundary without user-visible feedback
Submission checklist
- This is a bug, not a usage question.
- I added a clear and descriptive title.
- I searched existing issues and didn't find this.
- I can reproduce this with the latest released version.
- I included a minimal reproducible example and steps to reproduce.
Area (Required)
- deepagents (SDK)
- dcode
- talon
- acp
- evals
- daytona
- modal
- quickjs
- runloop
- vercel
- langsmith-sandbox
- Other / not sure / general
Related Issues / PRs
#5789, #6285 (+ PR #6249), #3011 — all closed as not planned, none covering this branch.
_resolve_path raises ValueError from two branches. #5789 and its maintainer-requested
refile #6285 both concern the .. substring branch (L206-207) and argue about which
paths should be accepted. This report concerns the containment branch (L211-213) and
argues nothing about acceptance — refusing the path is correct. #6285 itself notes that
"the subsequent resolve()/relative_to containment check is what actually prevents
escapes"; that check is the one raising here, and validate_path cannot screen it out
because the path contains no .. at all.
#3011 involves symlinks but a different exception (OSError/ELOOP) on a different path
(upload_files/download_files classification).
Reproduction Steps / Example Code (Python)
###
import os, tempfile
from pathlib import Path
from langchain.tools import ToolRuntime
from deepagents.backends.filesystem import FilesystemBackend
from deepagents.middleware.filesystem import FilesystemMiddleware
tmp = Path(tempfile.mkdtemp())
root, outside = tmp / "root", tmp / "outside"
root.mkdir(); outside.mkdir()
(outside / "dict.json").write_text('{"real": true}\n')
os.symlink(outside, root / "data", target_is_directory=True) # link leaves root
mw = FilesystemMiddleware(backend=FilesystemBackend(root_dir=root, virtual_mode=True))
tool = {t.name: t for t in mw.tools}["read_file"]
rt = ToolRuntime(state={"messages": []}, context=None, config={},
stream_writer=lambda *_: None, tool_call_id="c1", store=None)
tool.func(file_path="/data/dict.json", runtime=rt) # raises instead of returning
Self-contained: no model, no API key, no network. It drives the real `read_file` tool, so the
result is what an agent turn would receive. Equivalent through `create_deep_agent(...)` with
any model scripted to call `read_file(file_path="/data/dict.json")`.
A symlink pointing outside the workspace is what any "mount the dataset/config into the
agent's directory" step produces. On Windows, substitute an NTFS junction
(`mklink /J`) — symlinks there need elevation, and the failing check is platform-independent.Error Message and Stack Trace (if applicable)
# captured on Windows with an NTFS junction standing in for the symlink;
# only the temp-directory username is masked.
Traceback (most recent call last):
File "repro.py", line 20, in <module>
tool.func(file_path="/data/dict.json", runtime=rt)
File ".../deepagents/middleware/filesystem.py", line 2104, in sync_read_file
read_result = resolved_backend.read(validated_path, offset=offset, limit=limit)
File ".../deepagents/backends/filesystem.py", line 441, in read
resolved_path = self._resolve_path(file_path)
File ".../deepagents/backends/filesystem.py", line 213, in _resolve_path
raise ValueError(msg) from None
ValueError: Path:C:\Users\me\AppData\Local\Temp\repro_c8nx2b9b\outside\dict.json outside root directory: C:\Users\me\AppData\Local\Temp\repro_c8nx2b9b\root
Backend-level, six of seven methods leak the exception:
read -> RAISED ValueError: outside root directory
write -> RAISED ValueError: outside root directory
edit -> RAISED ValueError: outside root directory
delete -> RAISED ValueError: outside root directory
ls -> RAISED ValueError: outside root directory
glob -> RAISED ValueError: outside root directory
grep -> returned GrepResult(error=None) [graceful]
Tool-level, five of the seven tools let it escape (`glob`'s wrapper catches it,
`grep` never raises):
read_file(/data/dict.json) -> ESCAPED ValueError
write_file(/data/x.txt) -> ESCAPED ValueError
edit_file(/data/dict.json) -> ESCAPED ValueError
delete(/data/dict.json) -> ESCAPED ValueError
ls(/data) -> ESCAPED ValueError
glob(*, /data) -> ToolMessage[error] "Error: glob failed: ..."
grep(real, /data) -> ToolMessage[success] "No matches found"
For contrast, the same tool with the same exception type from the other validator:
read_file(/../outside/dict.json) -> ToolMessage[error] "Path traversal not allowed" [graceful]
read_file(/data/dict.json) -> ESCAPED ValueError
## Expected behaviour
An error `ToolMessage`, so the model can read it and pick another path -- exactly
what these tools already return for "file not found" and for `validate_path`
rejections. Whether the path *should* be readable is a separate question;
refusing it is correct. Refusing it by raising through the tool boundary is not.
## Additional context
`ls()` catches `ValueError` in its child loop (L363) but logs at `debug` and
skips, so the link vanishes from the listing rather than being reported:
ls('/') -> LsResult(error=None, entries=[])
The workspace looks empty while `read_file` on the same link raises. Agents
built on this get two contradictory views of the filesystem.Description
FilesystemBackend._resolve_path rejects any virtual path that, once resolved, falls outside
root_dir. A symlink inside the workspace whose target lives outside it is the ordinary way
to hit this.
It rejects by raising. read, write, edit, delete, ls and glob catch only
(OSError, RuntimeError), and the read_file tool wrapper does not guard the backend call,
so the ValueError escapes the tool and ends the graph run instead of surfacing as a tool
error. In our case it terminated the agent mid-run with exit code 0 and nothing logged.
_resolve_path's own docstring (L198-200) declares the exception the callers omit:
Raises: ValueError: If path traversal is attempted in `virtual_mode` or if the resolved path escapes the root directory.Handling is inconsistent within the same file:
Caller | except clause | Catches ValueError? -- | -- | -- grep() (L668) | except ValueError | accepted ls() child loop (L363) | except ValueError | accepted read() (L442), write() (L500), edit() (L543), delete() (L601), ls() (L292), glob() (L1343) | except (OSError, RuntimeError) | raisesTwo things suggest oversight rather than contract:
Source: langchain-ai/deepagents