security-guidance v2.0.8: sg-python.sh picks an MSIX-packaged Python that cannot read the plugin directory, so every hook fails with ENOENT
Component: plugins/security-guidance v2.0.8 — hooks/sg-python.sh
Platform: Windows 11 (26100), Git Bash (MSYS2)
Severity: Plugin is fully non-functional on affected machines. Every
UserPromptSubmit / PostToolUse / Stop / commit / push hook fires, fails, and
surfaces an error to the user. No security review actually runs.
Summary
sg-python.sh probes candidate interpreters with -c. A Microsoft Store
(MSIX) Python passes that probe but cannot open any file under
%APPDATA%\Roaming, which is exactly where the plugin lives. It therefore
wins the selection loop and then dies with ENOENT on the real script argument
— on every single hook invocation.
The script already anticipates the Store Python. Its comment reads:
python3typically resolves to the Microsoft Store stub … the Store stub fails the probe
On this machine the Store Python does not fail the probe. It is not the
old zero-byte "app install" stub; it is a fully functional interpreter that
answers -c correctly and reports 3.14. The guard is written for a stub that
errors out, so it never triggers.
Root cause
python3 resolves to an App Execution Alias:
~/AppData/Local/Microsoft/WindowsApps/python3.exe
-> /c/Program Files/WindowsApps/PythonSoftwareFoundation.PythonManager_26.3.240.0_x64__3847v3x7pw1km/python3.exeMSIX packages run with filesystem virtualization: AppData\Roaming is
redirected into the package's private store. The plugin is installed under
AppData\Roaming\Claude\..., so that path does not exist from the packaged
interpreter's point of view.
Note it is the location, not the path or the interpreter, that breaks:
AppData\Local\Temp reads fine from the same interpreter.
Evidence
A one-line print("ok") file, written next to the hook and run by each
interpreter — same file, same absolute path, same shell:
| interpreter | result |
|---|---|
python3 (MSIX Store package) |
can't open file '...': [Errno 2] No such file or directory |
python (python.org 3.12) |
ok |
Related permutations, all with the real hook script:
| invocation | exit |
|---|---|
python.exe <path> directly |
0 |
py -3 <path> |
0 |
python3 -c 'import sys; print(sys.version_info)' |
0 — reports 3.14 |
python3 <path> (what sg-python.sh execs) |
2 |
bash -x trace of the real invocation:
+ exec python3 'C:\Users\<user>\AppData\Roaming\Claude\...\hooks\security_reminder_hook.py'
C:\Users\<user>\AppData\Local\Python\pythoncore-3.14-64\python.exe: can't open file
'...\hooks\security_reminder_hook.py': [Errno 2] No such file or directoryRuled out, with evidence:
- File missing — no; 116 KB, executable, present.
sg-python.shmissing — no; present.cygpathmangling — no;cygpath -wemits correct single backslashes (verified by hexdump). The doubled backslashes in Python's message are its own%Rrepr formatting, not the real argument.- MAX_PATH — no; 205 characters, under 260.
- Interpreter too old — no; it reports 3.14.
Interpreter inventory on the affected machine
python3.13 / 3.12 / 3.11 / 3.10 -> not found
python3 -> WindowsApps MSIX alias (probe: 3.14) BROKEN for AppData\Roaming
python -> Programs/Python/Python312 (probe: 3.12) WORKS
py -3 -> AppData/Local/Python/pythoncore-3.14-64 WORKSpython3 is first in the loop, so the only broken candidate is the one chosen.
Suggested fix
Probe by executing a temp file in the same directory as the target script
rather than with -c. That exercises the same filesystem-virtualization domain
the real invocation will use, so a packaged interpreter that cannot read the
plugin directory is rejected regardless of how it got installed. This is
strictly more faithful than any allow/deny-list of interpreter paths, and needs
no Windows-specific branching.
+SG_PROBE_DIR=$(dirname "$1" 2>/dev/null) # before args are cygpath-converted
+
probe() {
- "$@" -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")' 2>/dev/null
+ local d t targ out rc
+ if [ -n "$SG_PROBE_DIR" ] && [ -w "$SG_PROBE_DIR" ]; then
+ d=""; t="$SG_PROBE_DIR/.sgprobe.$$.py"
+ else
+ d=$(mktemp -d 2>/dev/null) || return 1
+ t="$d/probe.py"
+ fi
+ printf '%s\n' 'import sys' \
+ 'print("%d.%d" % (sys.version_info[0], sys.version_info[1]))' > "$t" \
+ || { [ -n "$d" ] && rm -rf "$d" || rm -f "$t"; return 1; }
+ if command -v cygpath >/dev/null 2>&1; then targ=$(cygpath -w "$t"); else targ="$t"; fi
+ out=$("$@" "$targ" 2>/dev/null); rc=$?
+ if [ -n "$d" ]; then rm -rf "$d"; else rm -f "$t"; fi
+ [ "$rc" -eq 0 ] || return 1
+ printf '%s\n' "$out"
}Verified locally: selection moves from python3 to python, and the hook
returns exit 0. The temp file is removed on every path, including failure.
A narrower alternative is to skip any candidate resolving under
Microsoft\WindowsApps, but that hard-codes one packaging mechanism and would
miss other sandboxed interpreters.
Note on the user-visible failure mode
Each failure surfaces as a "Background security review found issues"
notification whose body is the ENOENT. The wording implies findings were
produced; in fact nothing ran. Two consequences worth considering:
- The plugin is silently providing no security coverage while appearing active.
- On a long session the notification repeats indefinitely and is easy to mistake for a genuine finding.
A distinct message for "the reviewer could not start" versus "the reviewer ran and found something" would make this far easier to diagnose.
Source: anthropics/claude-plugins-official