Problem
The modern-python/hooks/setup-shims.sh SessionStart hook prepends a python3 shim to PATH that always exit 1s. This breaks every Claude Code settings.json hook that invokes bare python3 /path/to/hook.py: the hook command silently fails because most are wrapped with 2>/dev/null || true (or rely on the harness treating exit 1 as a soft skip), so the user never sees the shim's stderr nudge.
Concrete impact on my setup (~25 user-defined hooks broken since 1.5.0 landed on my system on 2026-05-09):
- Stop hooks (worktree-archive-autoprep, check-fabricated-hook-attribution, check-fabricated-panelist)
- SessionEnd hooks (reconcile-worktree-shared)
- PreToolUse hooks (vault-command-nudges, block-raw-vault-git, several others)
- PostToolUse hooks (hookify-auto-commit, etc.)
The shim's intent is a teaching nudge for human-typed python3 -m pip install. In automated contexts, no human reads the nudge. The cost is silent breakage.
Repro
- Install
trailofbits/modern-pythonplugin. - Add any user hook to
~/.claude/settings.jsonthat runspython3 /path/to/script.py. - Trigger the hook. Script never executes; stderr goes to harness log only.
Suggested fix
Bypass the shim when CLAUDECODE=1 (Claude Code sets this in every subprocess: hooks, Bash tool, agents). The nudge still fires in plain terminal shells where the user typed python3 ... directly without invoking Claude Code.
Verified patch against 1.5.0 hooks/shims/python (top of file, before the case statement):
if [[ "${CLAUDECODE:-}" == "1" ]]; then
shim_dir="${BASH_SOURCE[0]%/*}"
shim_dir="$(cd "$shim_dir" && pwd)"
clean_path=":$PATH:"
clean_path="${clean_path//:$shim_dir:/:}"
clean_path="${clean_path#:}"
clean_path="${clean_path%:}"
PATH="$clean_path" exec "$cmd" "$@"
fiIf preferred, narrower variants:
- Skip only on
python3 /absolute/path/to/script.pyinvocations (let-m,-ckeep nudging). - Skip when stdin is not a tty.
CLAUDECODE=1 is the cleanest because it precisely targets the harm domain (Claude Code automation) without affecting the nudge's intended audience (humans typing in terminal).
Happy to send a PR.
Environment
- macOS, Claude Code 2.1.128
- modern-python plugin 1.5.0 (installed 2026-05-09 23:27 local)
- Affected hook surfaces: PreToolUse, PostToolUse, Stop, SessionStart, SessionEnd, UserPromptSubmit
Activity
5Confirming this on macOS, plus some measurements that refine the diagnosis and, I think, change what the right upstream fix is.
Environment: Claude Code 2.1.220, modern-python 1.5.2, macOS 15, uv 0.11.32, login shell zsh 5.9.
What actually happens on this version
I traced how the CLAUDE_ENV_FILE line gets applied:
- Each SessionStart hook is handed its own env file at
~/.claude/session-env/<session-id>/sessionstart-hook-<N>.sh. The plugin's is a singleexport PATH="<shims>:${PATH}". - Claude Code concatenates every such file, sorted by event class (
setup<sessionstart<cwdchanged<filechanged) and then by numeric index, and prepends the result to each Bash tool command string. Later lines win the PATH race. - Hook subprocesses are spawned from the parent process environment. As far as I can tell from the binary and from observing running processes, that concatenated preamble is not injected into hook spawns on 2.1.220.
So on this version the blast radius is Claude's own Bash tool calls, not hooks directly. A hook of mine that probes python3 -c '' works fine when Claude Code is launched normally from a terminal.
That said, the reported hook breakage is real and reachable: hooks inherit the parent process PATH, so any Claude Code launched from an already-poisoned shell propagates the broken PATH to all of its hooks. I reproduced exactly that by starting a nested claude -p from a Bash tool call - its SessionEnd hook died with the shim error. If you launch claude from a wrapper script, a nested agent, or a shell that sourced the env file, you get the reported symptom.
Three details worth knowing
1. The Bash tool shell here is zsh, not bash.
$0=/bin/zsh ZSH_VERSION=5.9 BASH_VERSION=<unset>Anything written to CLAUDE_ENV_FILE has to be POSIX. read -ra, printf -v, export -f, and trap -p are all bash-only and fail silently or loudly here. Worth keeping in mind for any fix that emits shell code.
2. Non-shell-builtin children inherit the poisoned PATH. This is the assertion that matters, and it rules out shell-function wrappers as a workaround:
$ /bin/sh -c 'command -v python3'
.../modern-python/1.5.2/hooks/shims/python3Anything Claude launches indirectly is affected: pre-commit style hook runners, Makefile recipes, tox, pipx run based tools, #!/usr/bin/env python3 scripts. A pre-commit hook whose entry is bare python3 -c ... fails with ERROR: Use \uv run python3 ...`` and reads to the operator like a genuine hook failure.
3. The shims are not uniform. hooks/shims/uv is well behaved: it intercepts only uv pip and execs the real uv for everything else. python, python3, pip, pip3, pipx never exec anything - they print and exit 1 on every invocation, pipx run included.
Why the proposed CLAUDECODE=1 bypass is a no-op
The stated goal is to keep the teaching nudge for interactive terminal use and skip it in automation. But setup-shims.sh exits early unless CLAUDE_ENV_FILE is set, and that variable is only ever set by Claude Code. The shims therefore never reach an interactive human shell in the first place - the only consumer is the agent. Gating on CLAUDECODE=1 removes the nudge everywhere it currently exists, which is the same outcome as deleting the shims, just less obviously.
Suggestion
If the goal is to keep teaching without breaking automation, the lever is which invocations fail, not which context:
- Let
python/python3exec the real interpreter. Running an interpreter is not the mistake the plugin is trying to prevent, and it is the invocation most likely to be reached indirectly. - Keep the hard failure for the genuinely environment-mutating commands:
pip install/uninstall,pipx install/inject/upgrade,python -m pip. - Consider passing
pipx runthrough, since it is the tool-execution path rather than an install path, and it is whatpipx-based hook runners call. - Optionally print the nudge to stderr and still exec, for the passthrough cases.
The skill text is loaded into the agent's context anyway, so it carries the "use uv" guidance regardless of whether a shim fails.
Local workaround
For anyone who wants python working now without disabling the plugin or editing the vendored copy: add a counter-hook that strips the shims directory back out of PATH. The plugin and its skill stay enabled.
1. ~/.claude/hooks/strip-modern-python-shims.sh (chmod +x):
#!/usr/bin/env bash
set -euo pipefail
# Counter-hook for trailofbits/skills#162.
# The emitted snippet MUST be POSIX: the Bash tool shell may be zsh, which has
# no `read -ra`, no `printf -v`, no `export -f`, and no `trap -p`.
[[ -n "${CLAUDE_ENV_FILE:-}" ]] || exit 0
# Leading newline: defensive, so we can never splice onto a previous line.
printf '\n' >>"$CLAUDE_ENV_FILE"
cat >>"$CLAUDE_ENV_FILE" <<'SNIPPET'
# --- trailofbits/skills#162: drop modern-python's fail-only PATH shims ---
__ctf_mp_filter() {
# Cheap gate: one pattern test, no loop, no forks. This is what runs before
# every command once PATH is already clean.
case ":$PATH:" in
*"/modern-python/"*"/hooks/shims"*) ;;
*) return 0 ;;
esac
__ctf_rest="${PATH:-}"; __ctf_kept=""; __ctf_first=1
while :; do
__ctf_d="${__ctf_rest%%:*}"
case "$__ctf_d" in
*/modern-python/*/hooks/shims|*/modern-python/*/hooks/shims/) ;;
*)
if [ "$__ctf_first" = 1 ]; then __ctf_kept="$__ctf_d"; __ctf_first=0
else __ctf_kept="$__ctf_kept:$__ctf_d"; fi ;;
esac
case "$__ctf_rest" in *:*) __ctf_rest="${__ctf_rest#*:}" ;; *) break ;; esac
done
PATH="$__ctf_kept"
export PATH
unset __ctf_rest __ctf_kept __ctf_first __ctf_d
# Fail loud, once: the gate matched but the per-entry walk removed nothing,
# so the plugin's layout no longer fits the pattern above.
case ":$PATH:" in
*"/modern-python/"*"/hooks/shims"*)
if [ -z "${__ctf_warned:-}" ]; then
__ctf_warned=1
echo "warn: modern-python shims survived the PATH filter" >&2
fi ;;
esac
}
__ctf_mp_guard() {
[ -n "${__ctf_busy:-}" ] && return 0
__ctf_busy=1
__ctf_mp_filter
unset __ctf_busy
}
__ctf_mp_filter # immediate pass; handles favorable hook ordering
# Stays armed for the life of the shell ON PURPOSE. See note below.
trap '__ctf_mp_guard' DEBUG
SNIPPET2. Register it in ~/.claude/settings.json (validate with jq empty afterwards - a syntax error there silently disables every SessionStart hook):
"SessionStart": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "bash \"$HOME/.claude/hooks/strip-modern-python-shims.sh\"" }
]
}
]3. Start a new session. A resumed session reuses its original env directory, so the fix will not appear there. To patch an already-running session in place, write the snippet into that session's directory at an index that sorts after the plugin's:
CLAUDE_ENV_FILE=~/.claude/session-env/<session-id>/sessionstart-hook-9.sh \
bash ~/.claude/hooks/strip-modern-python-shims.shTwo things that a simpler version of this gets wrong
The hook ordering is adverse in practice, so a plain PATH filter is not enough. Measured after install: my counter-hook landed at sessionstart-hook-1.sh and the plugin at sessionstart-hook-4.sh, meaning the plugin's export is concatenated after mine. A one-shot filter loses that race on every session. The DEBUG trap is what makes it order independent: it fires after the whole preamble has been sourced, and because it runs in the parent shell before the command is spawned, every child inherits the cleaned PATH. Index assignment across settings and plugin sources is undocumented and I have seen it vary (2, 3, 4) across sessions, so this is not something to verify once and rely on.
Do not make the trap self-disarming. I tried that first, to avoid running a filter before every command. It passes under bash and fails under zsh: the trap fires before the poisoning assignment, observes a clean PATH, disarms, and the plugin's export then stands unopposed. Keeping it armed and putting a single case pattern test at the top of the filter is what makes the always-on cost acceptable - one builtin, no loop and no forks, on the already-clean path.
Verification
In a fresh session, all of these should hold:
echo "$PATH" | tr : '\n' | grep 'modern-python.*hooks/shims' # no output
command -v python3 # real interpreter, not the shim
python3 --version # exit 0
python3 -c 'print(1)' # 1
pip --version # real pip, or an ordinary "command not found"
uv --version # unchanged
uv run python -c 'print(1)' # 1
/bin/sh -c 'command -v python3' # not the shim, this is the one that fails before the fixRollback is removing the SessionStart block and starting a new session. No other state is created.
Flagging that #255 (modern-python 1.6.0) narrowed these shims but does not fix this issue, so nobody reads that change and assumes it did.
What 1.6.0 changed: python -c, python -m <module> and python - now pass through to the real interpreter, and uv pip passes through when it carries --project, --directory, --target or -t. That was #207.
Your case is python3 /path/to/script.py from a hook, and a bare script path is still intercepted — deliberately, since that is the invocation uv run python exists to replace. So the ~25 hooks described here still break, and still break silently wherever the hook discards stderr.
The underlying disagreement this issue raises is unresolved and needs a decision before anyone can close it. This issue argues the shim should be inert inside Claude Code, because no human reads the nudge in an automated context. plugins/modern-python/README.md argues the opposite: catching Claude is the stated purpose of the shims. A CLAUDECODE=1 bypass would satisfy this issue and defeat the README's intent; keeping the current behavior does the reverse. Whoever picks that up has to settle which audience the shims serve, not just apply the patch.
One detail from #207 that is relevant here: the shims are on PATH, so they intercept every subprocess any tool spawns, not only commands Claude types. That is exactly why hook scripts are caught, and it is the property that makes the "coach the agent" framing leaky.
I went to write the suggested patch and stopped, because applying it as written looks like it would turn the shim off everywhere rather than narrowing it. Two things I checked:
1. Claude Code is the shim's only audience, not an exception to it.
hooks/setup-shims.sh installs the shim by writing its directory to $CLAUDE_ENV_FILE:
echo "export PATH=\"${shims_dir}:\${PATH}\"" >>"$CLAUDE_ENV_FILE"and it bails when that variable is unset:
if [[ -z "${CLAUDE_ENV_FILE:-}" ]]; then
echo "modern-python: CLAUDE_ENV_FILE not set; shims will not be installed" >&2
exit 0
fiCLAUDE_ENV_FILE is set by Claude Code, not by a plain terminal shell. So outside Claude Code the shim is not on PATH at all — the nudge cannot fire in a plain terminal today, and it would not "still fire" there after this patch either. CLAUDECODE=1 is not the automated-context special case; it is the whole installed footprint.
2. The refusal is tested behaviour, and #207 set a different precedent.
python-shim.bats asserts the nudge directly:
@test "exits non-zero for bare python" -> output contains "uv run python"
@test "exits non-zero for python script.py" -> output contains "uv run python script.py"So bypassing on CLAUDECODE=1 would fail the shim's own tests and remove the thing the shim exists to do.
And this is the same class of problem as #207 ("modern-python shims break prek hook installation and python3 -"), which the shim already cites in a comment. That was resolved by narrowing which invocations are intercepted — -c, -m and - pass through, a script path still refuses:
# `-c`, `-m` and `-` are not that. They read a program from the command line, a module
# already on the path, or stdin, and none of them resolves a script's dependencies.i.e. by argument shape, never by environment. Your own "narrower variant" — skip on python3 /absolute/path/to/script.py and let -m/-c keep nudging — is the one that matches how this was settled before.
The defect is real; I am only arguing about the discriminator. What actually breaks is that a user settings.json hook runs python3 /abs/hook.py, the shim exits 1, and the harness swallows it. An absolute script path is what hook configs use (they must work from any cwd) and is not how the agent tends to invoke a script inside a project, so it separates the two cases on the axis #207 already established.
I have not opened a PR for this yet, because the discriminator is a call I would rather you make than guess at — CLAUDECODE, an absolute script path, a non-tty stdin, or something else. Tell me which and I will write it against these tests.
Correction to my comment above: it was already here, and I should have read the thread before posting.
@antonbabenko made the CLAUDE_ENV_FILE argument on 2026-07-28, under the heading "Why the proposed CLAUDECODE=1 bypass is a no-op":
"
setup-shims.shexits early unlessCLAUDE_ENV_FILEis set, and that variable is only ever set by Claude Code. The shims therefore never reach an interactive human shell in the first place - the only consumer is the agent. Gating onCLAUDECODE=1removes the nudge everywhere it currently exists, which is the same outcome as deleting the shims, just less obviously."
That is the point I posted as a find. It is theirs, three months earlier.
And @dguido had already framed the decision on 2026-08-19:
"The underlying disagreement this issue raises is unresolved and needs a decision before anyone can close it. ... Whoever picks that up has to settle which audience the shims serve, not just apply the patch."
So my "this is a call I would rather you make than guess at" was restating a call that was already identified and explicitly parked for a maintainer. I added a third voice to a two-voice conclusion and implied it was new.
What I did wrong: I fetched the issue body (--json title,body) and the code, but never --json comments. I checked the source against the report and treated the report as the whole discussion. On an issue that had been open four months with two substantive comments, that is exactly the "concluded without verifying" failure I keep correcting elsewhere.
What I would add, if anything, is narrower than what I wrote. @antonbabenko's measurements and the original report disagree on where the breakage lands on current versions: they found the concatenated preamble is not injected into hook spawns on 2.1.220 and that hooks break mainly via an already-poisoned parent PATH, while the report describes direct hook breakage. That distinction decides whether a fix in the shim is even the right layer, and it is testable by whoever has the affected setup. I cannot test it here.
I am not proposing a fix. The audience question is a maintainer decision, both prior comments say so, and nothing I can verify resolves it.
Confirming this on macOS, plus some measurements that refine the diagnosis and, I think, change what the right upstream fix is.
Environment: Claude Code 2.1.220,
modern-python1.5.2, macOS 15,uv0.11.32, login shell zsh 5.9.What actually happens on this version
I traced how the
CLAUDE_ENV_FILEline gets applied:~/.claude/session-env/<session-id>/sessionstart-hook-<N>.sh. The plugin's is a singleexport PATH="<shims>:${PATH}".setup<sessionstart<cwdchanged<filechanged) and then by numeric index, and prepends the result to each Bash tool command string. Later lines win the PATH race.So on this version the blast radius is Claude's own Bash tool calls, not hooks directly. A hook of mine that probes
python3 -c ''works fine when Claude Code is launched normally from a terminal.That said, the reported hook breakage is real and reachable: hooks inherit the parent process PATH, so any Claude Code launched from an already-poisoned shell propagates the broken PATH to all of its hooks. I reproduced exactly that by starting a nested
claude -pfrom a Bash tool call - its SessionEnd hook died with the shim error. If you launchclaudefrom a wrapper script, a nested agent, or a shell that sourced the env file, you get the reported symptom.Three details worth knowing
1. The Bash tool shell here is zsh, not bash.
Anything written to
CLAUDE_ENV_FILEhas to be POSIX.read -ra,printf -v,export -f, andtrap -pare all bash-only and fail silently or loudly here. Worth keeping in mind for any fix that emits shell code.2. Non-shell-builtin children inherit the poisoned PATH. This is the assertion that matters, and it rules out shell-function wrappers as a workaround:
Anything Claude launches indirectly is affected: pre-commit style hook runners, Makefile recipes, tox,
pipx runbased tools,#!/usr/bin/env python3scripts. A pre-commit hook whose entry is barepython3 -c ...fails withERROR: Use \uv run python3 ...`` and reads to the operator like a genuine hook failure.3. The shims are not uniform.
hooks/shims/uvis well behaved: it intercepts onlyuv pipandexecs the realuvfor everything else.python,python3,pip,pip3,pipxnever exec anything - they print andexit 1on every invocation,pipx runincluded.Why the proposed
CLAUDECODE=1bypass is a no-opThe stated goal is to keep the teaching nudge for interactive terminal use and skip it in automation. But
setup-shims.shexits early unlessCLAUDE_ENV_FILEis set, and that variable is only ever set by Claude Code. The shims therefore never reach an interactive human shell in the first place - the only consumer is the agent. Gating onCLAUDECODE=1removes the nudge everywhere it currently exists, which is the same outcome as deleting the shims, just less obviously.Suggestion
If the goal is to keep teaching without breaking automation, the lever is which invocations fail, not which context:
python/python3exec the real interpreter. Running an interpreter is not the mistake the plugin is trying to prevent, and it is the invocation most likely to be reached indirectly.pip install/uninstall,pipx install/inject/upgrade,python -m pip.pipx runthrough, since it is the tool-execution path rather than an install path, and it is whatpipx-based hook runners call.The skill text is loaded into the agent's context anyway, so it carries the "use uv" guidance regardless of whether a shim fails.
Local workaround
For anyone who wants python working now without disabling the plugin or editing the vendored copy: add a counter-hook that strips the shims directory back out of PATH. The plugin and its skill stay enabled.
1.
~/.claude/hooks/strip-modern-python-shims.sh(chmod +x):2. Register it in
~/.claude/settings.json(validate withjq emptyafterwards - a syntax error there silently disables every SessionStart hook):3. Start a new session. A resumed session reuses its original env directory, so the fix will not appear there. To patch an already-running session in place, write the snippet into that session's directory at an index that sorts after the plugin's:
Two things that a simpler version of this gets wrong
The hook ordering is adverse in practice, so a plain PATH filter is not enough. Measured after install: my counter-hook landed at
sessionstart-hook-1.shand the plugin atsessionstart-hook-4.sh, meaning the plugin'sexportis concatenated after mine. A one-shot filter loses that race on every session. TheDEBUGtrap is what makes it order independent: it fires after the whole preamble has been sourced, and because it runs in the parent shell before the command is spawned, every child inherits the cleaned PATH. Index assignment across settings and plugin sources is undocumented and I have seen it vary (2, 3, 4) across sessions, so this is not something to verify once and rely on.Do not make the trap self-disarming. I tried that first, to avoid running a filter before every command. It passes under bash and fails under zsh: the trap fires before the poisoning assignment, observes a clean PATH, disarms, and the plugin's export then stands unopposed. Keeping it armed and putting a single
casepattern test at the top of the filter is what makes the always-on cost acceptable - one builtin, no loop and no forks, on the already-clean path.Verification
In a fresh session, all of these should hold:
Rollback is removing the
SessionStartblock and starting a new session. No other state is created.