Environment
- OS: Windows 11
- skillspector: v2.11.0 (also reproduced pinned to
langgraph==1.0.10, so it's not a langgraph version drift)
- Install:
uv tool install skillspector --from git+https://github.com/NVIDIA/skillspector.git
Summary
On Windows, if the directory being scanned resolves to an 8.3 short DOS path (e.g. C:\Users\HOANGP~1\... instead of C:\Users\Hoang Pham\... — this happens automatically at Windows logon for any account whose profile folder name contains a space, which is why %TEMP%/%TMP% often carry the short form even though the account name is the long one), every single file in the scan is marked missing_file_cache, execution_successful is false, and the risk score becomes meaningless — regardless of scan target content. It reproduces with a trivial two-file directory (one SKILL.md, one .py file, no symlinks, no unusual permissions).
Since skillspector scan <github-url> clones into its own temp directory, and that temp directory is short-form on any Windows machine with a spaced username, this silently breaks URL scans on those machines by default.
Root cause
skillspector/input_handler.py, function _open_regular_file_from_windows_handle (around line 405-414):
opened_path = _windows_final_path_name(get_final_path_name, handle, file_path)
if _windows_normalized_path(opened_path) != _windows_normalized_path(os.fspath(file_path)):
raise _UnsafeFileError(f"Could not safely open file: {file_path}")
This is a TOCTOU guard: after opening the file by handle, it re-derives the canonical path via GetFinalPathNameByHandleW and compares it against the originally-requested path string, to catch a symlink swap between the initial safety checks and the open.
GetFinalPathNameByHandleW always returns the long-form path (Windows API behavior). _windows_normalized_path (line 446-454) only strips the \\?\ prefix and applies os.path.normcase(os.path.normpath(os.path.abspath(path))) — it does not resolve 8.3 short names to long names. So when file_path was constructed from a short-name root (e.g. because skill_path/the clone target came from %TEMP%, which Windows shortens at logon whenever the profile folder name contains a space), the two strings never match even though they refer to the identical file, and the guard raises _UnsafeFileError unconditionally.
That exception propagates out of _read_file_cache in nodes/build_context.py as a NOT_REGULAR_FILE/OUT_OF_SCOPE ledger event for every file, so file_cache/local_file_cache end up empty. Every analyzer then independently reports missing_file_cache when it looks up path in file_cache (confirmed in static_yara.py, behavioral_ast.py, behavioral_taint_tracking.py, static_runner.py, bundled_execution_surface.py, semantic_security_discovery.py — all check path not in file_cache).
Repro
mkdir C:\Users\<you>\AppData\Local\Temp\sp_repro
"# test" > C:\Users\<you>\AppData\Local\Temp\sp_repro\SKILL.md
"def add(a,b): return a+b" > C:\Users\<you>\AppData\Local\Temp\sp_repro\helper.py
# Works (long form):
skillspector scan "C:\Users\<you>\AppData\Local\Temp\sp_repro" --no-llm --format json -o ok.json
# execution_successful: true
# Fails (short form of the identical directory):
skillspector scan "C:\Users\<SHORTNAME>~1\AppData\Local\Temp\sp_repro" --no-llm --format json -o bad.json
# execution_successful: false, missing_file_cache on every component
(Use dir /x in cmd, or fsutil file queryfileid, to find the 8.3 alias for a directory whose long name contains a space, if your own username doesn't happen to have one.)
Suggested fix
Compare file identity, not path strings. _open_regular_file_from_windows_handle already calls GetFileInformationByHandle and has nFileIndexHigh/nFileIndexLow/dwVolumeSerialNumber available in information. Comparing those (or the volume serial + file ID) against a GetFileInformationByHandle-equivalent lookup on the pre-open path — instead of string-comparing GetFinalPathNameByHandleW's output against the caller-supplied path — would catch a genuine symlink-swap race without producing false positives for 8.3/long-name aliasing, case differences, or other equivalent-but-not-identical path spellings.
A narrower, lower-risk alternative: resolve file_path to its long-form canonical path (e.g. via GetLongPathNameW, or Path.resolve(), which already round-trips 8.3 names correctly per Python's own pathlib on Windows) before constructing the comparison string in _windows_normalized_path, so both sides of the comparison are in the same canonical form.
Environment
langgraph==1.0.10, so it's not a langgraph version drift)uv tool install skillspector --from git+https://github.com/NVIDIA/skillspector.gitSummary
On Windows, if the directory being scanned resolves to an 8.3 short DOS path (e.g.
C:\Users\HOANGP~1\...instead ofC:\Users\Hoang Pham\...— this happens automatically at Windows logon for any account whose profile folder name contains a space, which is why%TEMP%/%TMP%often carry the short form even though the account name is the long one), every single file in the scan is markedmissing_file_cache,execution_successfulisfalse, and the risk score becomes meaningless — regardless of scan target content. It reproduces with a trivial two-file directory (oneSKILL.md, one.pyfile, no symlinks, no unusual permissions).Since
skillspector scan <github-url>clones into its own temp directory, and that temp directory is short-form on any Windows machine with a spaced username, this silently breaks URL scans on those machines by default.Root cause
skillspector/input_handler.py, function_open_regular_file_from_windows_handle(around line 405-414):This is a TOCTOU guard: after opening the file by handle, it re-derives the canonical path via
GetFinalPathNameByHandleWand compares it against the originally-requested path string, to catch a symlink swap between the initial safety checks and the open.GetFinalPathNameByHandleWalways returns the long-form path (Windows API behavior)._windows_normalized_path(line 446-454) only strips the\\?\prefix and appliesos.path.normcase(os.path.normpath(os.path.abspath(path)))— it does not resolve 8.3 short names to long names. So whenfile_pathwas constructed from a short-name root (e.g. becauseskill_path/the clone target came from%TEMP%, which Windows shortens at logon whenever the profile folder name contains a space), the two strings never match even though they refer to the identical file, and the guard raises_UnsafeFileErrorunconditionally.That exception propagates out of
_read_file_cacheinnodes/build_context.pyas aNOT_REGULAR_FILE/OUT_OF_SCOPEledger event for every file, sofile_cache/local_file_cacheend up empty. Every analyzer then independently reportsmissing_file_cachewhen it looks uppathinfile_cache(confirmed instatic_yara.py,behavioral_ast.py,behavioral_taint_tracking.py,static_runner.py,bundled_execution_surface.py,semantic_security_discovery.py— all checkpath not in file_cache).Repro
(Use
dir /xin cmd, orfsutil file queryfileid, to find the 8.3 alias for a directory whose long name contains a space, if your own username doesn't happen to have one.)Suggested fix
Compare file identity, not path strings.
_open_regular_file_from_windows_handlealready callsGetFileInformationByHandleand hasnFileIndexHigh/nFileIndexLow/dwVolumeSerialNumberavailable ininformation. Comparing those (or the volume serial + file ID) against aGetFileInformationByHandle-equivalent lookup on the pre-open path — instead of string-comparingGetFinalPathNameByHandleW's output against the caller-supplied path — would catch a genuine symlink-swap race without producing false positives for 8.3/long-name aliasing, case differences, or other equivalent-but-not-identical path spellings.A narrower, lower-risk alternative: resolve
file_pathto its long-form canonical path (e.g. viaGetLongPathNameW, orPath.resolve(), which already round-trips 8.3 names correctly per Python's ownpathlibon Windows) before constructing the comparison string in_windows_normalized_path, so both sides of the comparison are in the same canonical form.