Repository navigation
fix(agent): on Windows the file tools take a device name such as nul for a workspace file #895
Description
Summary
resolve_path (raven/agent/tools/filesystem.py) is the path policy behind read_file, write_file, edit_file, list_dir, grep/find, deliver_files, the RPC file viewer and turn attachments. On Windows it accepts a reserved DOS device name as an ordinary file inside the workspace. write_file with path="nul" should therefore send the content to the NUL device and still answer Successfully wrote ... bytes to <workspace>\nul, with a diff and a file_change for a file that was never created.
Why the fence lets it through: resolve_path joins a relative path onto the workspace and then calls Path.resolve(), which on Windows is ntpath.realpath. In CPython 3.12 that function special-cases a device only when the whole path is exactly nul (if normcase(path) == normcase(devnull): return '\\\\.\\NUL'), so <workspace>\nul falls through to _getfinalpathname, whose non-strict fallback hands the joined path back unchanged; relative_to(<workspace>) then accepts it.
Which names reach a device depends on the Windows version. nul maps to the NUL device in every directory on every version, Windows 11 included (CPython issue 95486). On Windows 10 and earlier so do CON, PRN, AUX, COM0-COM9 and LPT0-LPT9, with or without an extension (nul.md is NUL), per Microsoft's "Naming Files, Paths, and Namespaces"; Windows 11 treats most of them as ordinary names once they carry an extension.
This is not a write outside the workspace: the data goes to a device rather than to another path. The harm is a tool result that reports a save that did not happen (and, for CON on Windows 10, file content written to the console).
Found while reviewing #890, which adds a control-character refusal to resolve_path and leaves this case as it was. resolve_path is unchanged on main at c3d2821.
Steps to reproduce
Not run on Windows yet: I have no Windows machine, and CI runs no Windows job over the file-tool tests. On Windows, with Raven installed:
import asyncio, pathlib, tempfile
from raven.agent.tools.filesystem import WriteFileTool
ws = pathlib.Path(tempfile.mkdtemp())
tool = WriteFileTool(workspace=ws, allowed_dirs=(ws,))
for name in ("nul", "nul.md", "con.md", "report.md"):
r = asyncio.run(tool.execute(path=name, content="hello\n"))
print(name, "->", getattr(r, "model_text", r))
print("on disk:", sorted(p.name for p in ws.iterdir()))On Linux the same script writes four regular files, which is the expected shape.
Expected behavior
resolve_path refuses a reserved device name with a PermissionError, as it does for a path outside the allowed directories, so every caller reports an error rather than a save. Python 3.13's os.path.isreserved (absent from 3.12, which Raven supports) defines the set: device names with or without an extension in any component, CONIN$/CONOUT$, and also names ending in a dot or a space and name:stream paths, which have the same reported-path-is-not-the-file shape on Windows.
Actual behavior
Expected from the code, not yet observed: nul -> Successfully wrote ... bytes to <workspace>\nul with no nul on disk on any Windows version; on Windows 10 and earlier nul.md and con.md behave the same way.
Environment
OS: Windows (derived from CPython 3.12.13 Lib/ntpath.py and the Win32 naming rules; not reproduced)
Python: 3.12
Raven: main at c3d2821
Logs or screenshots
None yet; the script above prints them.
Activity
akushonkamen commentedon Oct 11, 2026
Confirmed —
resolve_path(raven/agent/tools/filesystem.py:57-76) joins the name onto the workspace and callsPath.resolve(); on Windows that isntpath.realpath, which in CPython 3.12 only special-cases a path that is exactlynul, so<workspace>\nulcomes back unchanged and the allowed_dirs prefix check (:67-75) accepts it. There is no reserved-name check anywhere in the file (:154_FsTool._resolveis the single choke point shared by all file tools).I'm working on a fix: reject reserved DOS device names in the final path segment (guarded so POSIX filenames like
nul.mdare unaffected), with tests at the predicate/fence level. Heads-up that this contribution is prepared with AI assistance (Claude Code) and submitted under my account.akushonkamen commentedon Oct 11, 2026
PR is up: #918. Red-to-green at the
resolve_pathfence (device names ->PermissionErroron Windows, unchanged on POSIX), file suite 54 passed, related subset 281 passed / 7 skipped, full suite failures all reproduced on cleanmain(pre-existing environment). Details and exact commands in the PR description.LivXue commentedon Oct 12, 2026
Correction to the Summary: the COM and LPT device names are
COM1-COM9andLPT1-LPT9, notCOM0-COM9andLPT0-LPT9. Microsoft's "Naming Files, Paths, and Namespaces" listsCOM1-COM9andLPT1-LPT9together with the forms ending in the superscript digits one, two and three, and has noCOM0orLPT0; CPython 3.13'sntpath.isreserveduses the same COM and LPT range. The rest of the Summary and the Expected behavior section are unchanged. The original wording is left in place because #918 cites it.fix(agent): hold windows reserved names in every path component and s…