Repository navigation
fix(agent): refuse windows device names in resolve_path - #918
akushonkamen wants to merge 1 commit into
Conversation
On Windows, resolve_path took a reserved DOS device name for an ordinary workspace file: write_file with path="nul" sent the content to the NUL device and answered success for a file that never existed, diff and file_change included. Path.resolve() cannot catch one, since ntpath.realpath maps only a path that is exactly "nul" to \\.\NUL and hands <workspace>\nul back unchanged, and the allowed-dir check then accepts it. Refuse a device name in the final path segment with a PermissionError, the same refusal an out-of-fence path gets, so every caller (the five file tools, deliver_files, the RPC file viewer and the turn attachments) answers an error instead of a save. The check is gated to Windows: there nul maps to a device in every directory on every version (CPython issue 95486) and the whole set does on Windows 10 and earlier, with or without an extension; off Windows a file called nul stays a legal name. Co-authored-by: GLM (glm-5.3-flash) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
Blocking: the reserved-device guard must cover every path component and all Windows COM/LPT aliases.
I found one incomplete case, marked inline.
Coverage: I reviewed the full github/main...HEAD diff, the issue and PR history, every direct resolve_path caller, the unchanged target implementation, backward compatibility of the Windows-only gate, and the added tests for weakening or false-green behavior. I also checked the repository rules in AGENTS.md and the architecture/domain routing in CONTEXT-MAP.md; the shared resolver remains the correct architectural choke point.
Verification: uv sync --extra dev completed; uv run pytest tests/test_filesystem_tools.py -q passed with 54 tests; git diff --check github/main...HEAD passed. A direct predicate probe confirmed that nul/report.md, COM¹.txt, and LPT²/log.txt are not caught. No live Windows run was available.
| ) -> Path: | ||
| """Resolve path against workspace (if relative) and enforce the allowed roots.""" | ||
| p = Path(path).expanduser() | ||
| if _WINDOWS and _is_device_name(p.name): |
There was a problem hiding this comment.
Blocking: scan every path component and include Windows' superscript COM/LPT aliases.
This checks only p.name, so nul/report.md passes even though Windows applies its naming rules to directory components too. The predicate also returns false for COM¹.txt and LPT²; Microsoft documents those aliases as reserved, and CPython 3.13's ntpath.isreserved both scans all components and includes superscript 1/2/3 in its COM/LPT set. Consequently the central fence still accepts reserved device paths, and a final COM¹.txt can address a serial device instead of the reported file. The current subdir/nul test covers only the opposite component order. Please cover both gaps and add regressions for them.
|
Windows compatibility review: three findings reproduced on native Windows 11 (build 26300), Python 3.13.12. Scope: both changed files and their filesystem/search, delivery, viewer and attachment callers at head
Verification used uv run pytest tests/test_filesystem_tools.py -n 0 -x --tb=short --basetemp "$reviewRunDir/pytest-filesystem"
# 52 passed, 1 failed at line 328; stopped before the last test.
uv run pytest tests/test_filesystem_tools.py::test_a_read_of_a_device_name_answers_an_error_not_a_missing_file -n 0 -x --tb=short --basetemp "$runDir/pytest-read"
# Remaining test: 1 passed.
uv run python ../probe_device_paths.py
# Native resolver: NUL: accepted and normalized to NUL; bare nul/nul.md refused.
uv run python ../probe_write_device.py --source head
uv run python ../probe_write_device.py --source base
# Findings 1's real write/control results on both exact revisions.
uv run python ../probe_ordinary_names.py --source head
uv run python ../probe_ordinary_names.py --source base
# Findings 2-3: regular COM0/LPT0 files; NUL exists before any write.
git diff --check 4acc63cebd0752652194f9e4989628da29a8a25d...ddb81af819703299d2111defade49fbc038ea3d1
# Passed.The scratch probes import the actual checked-out modules and use real file tools; source paths were verified. Serial/console aliases were checked only as strings, without opening hardware. No Windows 10, remote UNC share, full repository suite or live viewer session was tested. Non-failing shared-setup idle warnings occurred in both pytest runs. |
Summary
On Windows, the file tools took a DOS device name for an ordinary workspace file:
write_filewithpath="nul"sent the content to the NUL device and answeredsuccess for a file that never existed,
diffandfile_changeincluded. The sameholds for the rest of the reserved set under Windows' naming rules: a first
extension and trailing spaces are stripped before the device comparison, so
nul.mdand
conare devices too, and on Windows 10 and earlier the whole set is reservedin every directory.
Root cause:
resolve_path(raven/agent/tools/filesystem.py:57-76) joins the nameonto the workspace and calls
Path.resolve(). On Windows that isntpath.realpath,which maps only a path that is exactly
nulto\\.\NULand hands<workspace>\nulback unchanged, so the
allowed_dirsprefix check then accepts it, and the file hasno reserved-name check anywhere. CPython tracks the general "device name inside a
longer path" gap as issue 95486.
Fix: right after
expanduser,resolve_pathruns a pure string guard on the finalsegment - strip the first extension and any trailing spaces, uppercase, and compare
against the DOS reserved set (CON, PRN, AUX, NUL, CONIN$, CONOUT$, COM0-9, LPT0-9,
matching CPython 3.13's
os.path.isreserveddevice names). On a match it raisesPermissionErrorwith a remedy, the same refusal a path outside the alloweddirectories gets. The guard is gated on
_WINDOWS(snapshotted fromos.name == "nt"at import), and every consumer reaches it through this singlechoke point - the five file tools,
deliver_files, the RPC file viewer and the turnattachments - so one check covers them all. Off Windows nothing changes: a file
called
nulstays a legal name, and the new tests cover both sides of the gate.This fix was prepared with AI assistance; attribution follows the repo's AGENTS.md
convention via the commit's
Co-authored-bytrailer.Type
Verification
All commands run with
timeout, on this branch's head with a clean tree, againstthe repo
.venv.timeout 60 .venv/bin/pythoncallingresolve_pathfornul/nul.md/con.md/report.md-> all four resolvedas ordinary paths (device names accepted);
pytest tests/test_filesystem_tools.py-> ImportError (
_is_device_nameabsent).os.name == "nt"simulated): same inline script ->nul,nul.mdandcon.mdraisePermissionError,report.mdresolves normally.timeout 120 .venv/bin/python -m pytest tests/test_filesystem_tools.py -q -p no:cacheprovider-> 54 passed.timeout 300 .venv/bin/python -m pytest tests/ -k "filesystem or file_tool or fence" -q -p no:cacheprovider-> 281 passed, 7 skipped.resolve_path(5 test files): 288 passed, 1 failed(
test_a_host_without_libreoffice_says_so; reproduced identical red on a cleantree -> pre-existing environment failure).
timeout 590 .venv/bin/python -m pytest tests -q --ignore=tests/manual -p no:cacheprovider-> 27973 passed, 128 skipped, 4 failed (a2a interop /rpc_files libreoffice / simulation suite / simulation scenario; each reproduced
identical red on clean
origin/main-> pre-existing environment failures, not newregressions).
timeout 120 uvx ruff@latest check raven/-> All checks passed;uv run ruff formatapplied (touches the added lines only),ruff check-> Allchecks passed.
mainunchanged since the branch was cut, so the numbersabove stand):
timeout 120 .venv/bin/python -m pytest tests/test_filesystem_tools.py -q -p no:cacheprovider-> 54 passed (2.78s).Not run: a real Windows end-to-end pass (development host is macOS, no Windows
environment available). The refusal is gated on
_WINDOWSand was verified at thelogic level on both sides of the gate; the Windows-side behaviour rests on the
CPython
ntpathsource (read at 3.13) and issue 95486, not on a live run.No docs change: no document describes the file tools' path handling at this level.
Risk
User-visible change is Windows-only: the file tools now refuse a reserved device
name in the final path segment instead of silently opening the device. Off Windows
the gate is off and behaviour is unchanged.
The change closes a silent write-to-device hole (content discarded, fake success
receipt); it adds a refusal, removes no capability, and cannot widen any path.
POSIX filenames like
nulornul.mdstay legal; on Windows such names neveraddressed a real file through these tools, so no working workflow breaks.
Rollback is a revert of the single commit; the guard is one string check at one
fence point with no persistent state.
Related Issues
Fixes #895