Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2271ccd
Fix duplicated word in CHANGELOG.md
genesisdayabl-droid Aug 13, 2026
3128f7f
docs: fix stale hooks documentation link in bash_command_validator_ex…
cassiacarollinee-ship-it Aug 7, 2026
dbfb276
fix(skills): use spec-conformant names in plugin-dev and hookify skills
bechor25 Aug 9, 2026
802060f
validate-agent.sh: don't abort at the first warning (set -e + ((x++))…
bcherny Aug 25, 2026
aa896d7
fix(plugin-dev): handle wrapped hook schemas and optional matchers in…
erichanwang Aug 6, 2026
0714b7e
fix(security-guidance): skip XSS warnings in docs
yxl-lab Aug 11, 2026
44dda9e
fix(security-guidance): preserve Python probe errors
aayush598 Aug 14, 2026
4a9628f
fix(pr-review-toolkit): repair invalid YAML frontmatter in all agents
anishsamant Aug 16, 2026
6c711ad
fix(plugins): run ralph-wiggum and output-style .sh hooks through bas…
claude Sep 20, 2026
0688b48
fix(security): address yaml injection and symlink credential overwrit…
alifakbxr Oct 2, 2026
efcfac1
fix(security-guidance): make ** glob patterns match zero-depth paths
anishsamant Oct 2, 2026
b6c721e
security-guidance: keep denied and secret files out of the reviewer's…
claude Oct 2, 2026
00b4f67
fix(security-guidance): launch the Python interpreter once per hook, …
claude Oct 2, 2026
4a6aa3a
Fix examples/gateway/gcp/setup.sh exiting silently when gcloud is mis…
Yyunozor Jul 29, 2026
de579ad
Fix examples/gateway/aws/setup.sh aborting on stock macOS bash 3.2
Yyunozor Jul 29, 2026
a6b1f54
Fix: provision poppler-utils for PDF support in devcontainers/scripts
Jul 28, 2026
ff701de
docs: fix security-guidance plugin hook type and pattern count in plu…
Woohyeon-Hong Jul 27, 2026
3de7ac6
fix(examples/settings): make bash-sandbox example fail closed when sa…
mholovetskyi Jul 26, 2026
ce1d5cd
Log closed issues as closure events in Statsig
fallintoplace Jul 25, 2026
32cf711
Handle worktree paths with spaces in clean_gone
fallintoplace Jul 25, 2026
5d6b250
fix(scripts): paginate comments and reactions in auto-close-duplicates
serhiileniv Jul 23, 2026
40a5c18
fix(devcontainer): block IPv6 egress to close firewall allowlist bypass
mholovetskyi Jul 26, 2026
8fdc9c3
fix(ralph-wiggum): stop parsing /ralph-loop prompt text as shell code
serhiileniv Jul 23, 2026
61f7b74
docs: add MessageDisplay hook guidance
iCodeCraft Aug 2, 2026
a1d0fc8
fix(devcontainer): don't abort firewall setup when an optional domain…
ozdemirsarman Jul 27, 2026
610f266
Merge pull request #2 from adri22235/fix/98929-sg-python-single-launch
adri22235 Oct 2, 2026
fbf75bc
Merge pull request #5 from adri22235/integration/page2-fixes
adri22235 Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(security-guidance): launch the Python interpreter once per hook, …
…not twice

sg-python.sh probed each candidate and then exec'd the chosen one, so every
hook cost at least two interpreter launches. On Windows with the Python
Install Manager, each launch of the WindowsApps alias starts an AppX update
that leaks ~1.6 MB in AppXSvc, which exhausted system commit in the report.

Cache the name of the candidate that passed the probe (python3, python or
py -3 only; never run arbitrary file contents) in ~/.claude/security/python-cmd.
Later hooks exec it directly with a single launch. The entry expires after a
day and is ignored when the command is no longer on PATH. Cache failures never
block the hook.

Not addressed here: preferring real interpreter paths over WindowsApps aliases.

Addresses upstream issue 98929 (Windows: py -3 launched twice per hook).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LoLAv3aLaRJocsRMcjFbpv
  • Loading branch information
Claude committed Oct 2, 2026
commit 00b4f673363e26a3b2922c277ae6b018e58c2e0e
31 changes: 31 additions & 0 deletions plugins/security-guidance/hooks/sg-python.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,29 @@
# "${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py"
set -e

# Fast path: reuse the candidate a previous run already probed. Every probe is
# an extra interpreter launch, and on Windows the Python Install Manager
# aliases (py, python, python3 under WindowsApps) start an AppX update per
# activation that leaks memory in AppXSvc, so probing and then exec'ing doubles
# that cost on every hook (anthropics/claude-code#98929). Only the candidate
# *name* is cached, and only if it is one of the fixed names below, so the
# file's contents are never run as-is. The entry expires after a day, and is
# ignored when the command is no longer on PATH, so a changed or removed
# interpreter falls back to probing.
cache_file="${SG_PYTHON_CACHE:-${HOME:+$HOME/.claude/security/python-cmd}}"
if [ -n "$cache_file" ] && [ -f "$cache_file" ] \
&& [ -n "$(find "$cache_file" -mmin -1440 2>/dev/null)" ]; then
cached=$(head -n 1 "$cache_file" 2>/dev/null || true)
case "$cached" in
"python3"|"python"|"py -3")
if command -v "${cached%% *}" >/dev/null 2>&1; then
# shellcheck disable=SC2086
exec $cached "$@"
fi
;;
esac
fi

# Capture probe stderr so the all-candidates-failed path can report useful
# diagnostics. Logging is best-effort: if the temp file cannot be created,
# fall back to the previous stderr-suppression behavior.
Expand Down Expand Up @@ -49,6 +72,14 @@ for cmd in "python3" "python" "py -3"; do
if [ -n "$errlog" ]; then
rm -f "$errlog"
fi
# Best-effort: write via a temp name so concurrent hooks never read a
# half-written file, and never let a cache failure block the hook.
if [ -n "$cache_file" ]; then
{ mkdir -p "$(dirname "$cache_file")" \
&& printf '%s\n' "$cmd" > "$cache_file.$$" \
&& mv -f "$cache_file.$$" "$cache_file"; } 2>/dev/null \
|| rm -f "$cache_file.$$" 2>/dev/null || true
fi
# shellcheck disable=SC2086
exec $cmd "$@"
fi
Expand Down
83 changes: 81 additions & 2 deletions plugins/security-guidance/tests/test-sg-python.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,13 @@ run() {
shift 2
: > "$FAKE_BIN/run.out"
: > "$FAKE_BIN/run.err"
# SG_PYTHON_CACHE points the shim's interpreter cache into $FAKE_BIN so no
# test reads or writes the real ~/.claude/security/python-cmd. reset_bin
# clears it, so every test starts cold; runs inside one test share it.
if [ -n "$extra_env" ]; then
env "$extra_env" PATH="$path" bash "$SHIM" "$@" > "$FAKE_BIN/run.out" 2> "$FAKE_BIN/run.err"
env "$extra_env" SG_PYTHON_CACHE="${SG_TEST_CACHE:-$FAKE_BIN/py-cache}" PATH="$path" bash "$SHIM" "$@" > "$FAKE_BIN/run.out" 2> "$FAKE_BIN/run.err"
else
env PATH="$path" bash "$SHIM" "$@" > "$FAKE_BIN/run.out" 2> "$FAKE_BIN/run.err"
env SG_PYTHON_CACHE="${SG_TEST_CACHE:-$FAKE_BIN/py-cache}" PATH="$path" bash "$SHIM" "$@" > "$FAKE_BIN/run.out" 2> "$FAKE_BIN/run.err"
fi
RC=$?
OUT="$(cat "$FAKE_BIN/run.out")"
Expand Down Expand Up @@ -161,6 +164,82 @@ after="$(ls "$T7_TMP"/tmp.* 2>/dev/null | wc -l)"
[ "$before" = "$after" ] && ok "T7: no temp files leaked (before=$before after=$after)" \
|| bad "T7: temp files leaked (before=$before after=$after)"

###
# T8 — #98929: the shim must not launch the interpreter twice per hook. The
# first run probes then execs (2 launches) and records the candidate; later
# runs reuse it and launch the interpreter exactly once.
###
reset_bin
fake python3 'echo x >> "$(dirname "$0")/launches"' \
'if [ "$2" = "import sys; print(sys.version_info[0])" ]; then echo 3; else echo "hook ran"; fi'
run "" "$FAKE_BIN:/usr/bin:/bin" -c 'print("hook")'
assert_rc 0 "T8: first run succeeds"
[ "$(wc -l < "$FAKE_BIN/launches")" -eq 2 ] && ok "T8: cold run launches twice (probe + hook)" \
|| bad "T8: cold run launch count is $(wc -l < "$FAKE_BIN/launches"), want 2"
[ "$(cat "$FAKE_BIN/py-cache" 2>/dev/null)" = "python3" ] && ok "T8: candidate name cached" || bad "T8: cache not written"
: > "$FAKE_BIN/launches"
run "" "$FAKE_BIN:/usr/bin:/bin" -c 'print("hook")'
assert_rc 0 "T8: cached run succeeds"
assert_out "hook ran" "T8: cached run executes the hook"
[ "$(wc -l < "$FAKE_BIN/launches")" -eq 1 ] && ok "T8: warm run launches once" \
|| bad "T8: warm run launch count is $(wc -l < "$FAKE_BIN/launches"), want 1"

###
# T9 — the multi-word `py -3` candidate is cached and reused with its flag.
###
reset_bin
fake python3 'exit 1'
fake python 'exit 1'
fake py 'echo x >> "$(dirname "$0")/launches"' \
'if [ "$3" = "import sys; print(sys.version_info[0])" ]; then echo 3; else echo "received:$*"; fi'
run "" "$FAKE_BIN:/usr/bin:/bin" -c 'print("one")'
[ "$(cat "$FAKE_BIN/py-cache")" = "py -3" ] && ok "T9: 'py -3' cached as one candidate" || bad "T9: cache holds '$(cat "$FAKE_BIN/py-cache" 2>/dev/null)'"
: > "$FAKE_BIN/launches"
run "" "$FAKE_BIN:/usr/bin:/bin" -c 'print("two")'
assert_out 'received:-3 -c print("two")' "T9: cached py -3 keeps its flag"
[ "$(wc -l < "$FAKE_BIN/launches")" -eq 1 ] && ok "T9: warm run launches py once" || bad "T9: warm run launch count is $(wc -l < "$FAKE_BIN/launches"), want 1"

###
# T10 — a cache entry is never trusted blindly: unknown content is ignored and
# never run, a command that left PATH falls back to probing, and an entry older
# than a day is re-probed.
###
reset_bin
fake python3 'if [ "$2" = "import sys; print(sys.version_info[0])" ]; then echo 3; else echo "real run"; fi'
printf '%s\n' 'touch /tmp/sg-cache-injection' > "$FAKE_BIN/py-cache"
run "" "$FAKE_BIN:/usr/bin:/bin" -c 'print("x")'
assert_rc 0 "T10: unrecognised cache content is ignored"
assert_out "real run" "T10: probing still selects the interpreter"
[ ! -e /tmp/sg-cache-injection ] && ok "T10: cache content never executed" || { bad "T10: cache content was executed"; rm -f /tmp/sg-cache-injection; }
[ "$(cat "$FAKE_BIN/py-cache")" = "python3" ] && ok "T10: bad entry replaced by a probed candidate" || bad "T10: entry not replaced"

reset_bin
# `py` is absent from PATH. python3 is stubbed to fail so a real /usr/bin/python3
# on the host cannot be picked up by the fallback probe.
fake python3 'exit 1'
fake python 'if [ "$2" = "import sys; print(sys.version_info[0])" ]; then echo 3; else echo "via python"; fi'
printf '%s\n' 'py -3' > "$FAKE_BIN/py-cache"
run "" "$FAKE_BIN:/usr/bin:/bin" -c 'print("x")'
assert_out "via python" "T10: cached command missing from PATH falls back to probing"
[ "$(cat "$FAKE_BIN/py-cache")" = "python" ] && ok "T10: stale entry refreshed" || bad "T10: stale entry kept"

reset_bin
fake python3 'echo x >> "$(dirname "$0")/launches"' \
'if [ "$2" = "import sys; print(sys.version_info[0])" ]; then echo 3; else echo "ok"; fi'
printf '%s\n' 'python3' > "$FAKE_BIN/py-cache"
touch -d '2 days ago' "$FAKE_BIN/py-cache" 2>/dev/null || touch -t 200001010000 "$FAKE_BIN/py-cache"
run "" "$FAKE_BIN:/usr/bin:/bin" -c 'print("x")'
[ "$(wc -l < "$FAKE_BIN/launches")" -eq 2 ] && ok "T10: expired entry is re-probed" || bad "T10: expired entry launch count is $(wc -l < "$FAKE_BIN/launches"), want 2"

###
# T11 — an unwritable cache location never breaks the hook.
###
reset_bin
fake python3 'if [ "$2" = "import sys; print(sys.version_info[0])" ]; then echo 3; else echo "still ok"; fi'
SG_TEST_CACHE="/nonexistent-sgtest-dir/sub/python-cmd" run "" "$FAKE_BIN:/usr/bin:/bin" -c 'print("x")'
assert_rc 0 "T11: hook runs when the cache cannot be written"
assert_out "still ok" "T11: payload executes without a cache"

echo
echo "passed: $pass, failed: $fail"
[ "$fail" -eq 0 ] || exit 1
Loading