Skip to content

fix(agent): on Windows the file tools take a device name such as nul for a workspace file #895

Open

Description

@LivXue

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

  1. akushonkamen commented on Oct 11, 2026

    @akushonkamen
    Contributor

    Confirmed — resolve_path (raven/agent/tools/filesystem.py:57-76) joins the name onto the workspace and calls Path.resolve(); on Windows that is ntpath.realpath, which in CPython 3.12 only special-cases a path that is exactly nul, so <workspace>\nul comes back unchanged and the allowed_dirs prefix check (:67-75) accepts it. There is no reserved-name check anywhere in the file (:154 _FsTool._resolve is 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.md are 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.

  2. akushonkamen commented on Oct 11, 2026

    @akushonkamen
    Contributor

    PR is up: #918. Red-to-green at the resolve_path fence (device names -> PermissionError on Windows, unchanged on POSIX), file suite 54 passed, related subset 281 passed / 7 skipped, full suite failures all reproduced on clean main (pre-existing environment). Details and exact commands in the PR description.

  3. LivXue commented on Oct 12, 2026

    @LivXue
    MemberAuthor

    Correction to the Summary: the COM and LPT device names are COM1-COM9 and LPT1-LPT9, not COM0-COM9 and LPT0-LPT9. Microsoft's "Naming Files, Paths, and Namespaces" lists COM1-COM9 and LPT1-LPT9 together with the forms ending in the superscript digits one, two and three, and has no COM0 or LPT0; CPython 3.13's ntpath.isreserved uses 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions