Skip to content

Windows: every scan fails with missing_file_cache / execution_successful: false when the skill path resolves to an 8.3 short DOS name #481

Description

@hoangthienpham

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.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions