[BUG] PreToolUse hook without the executable bit fails open: deny guards silently stop guarding, one Permission denied warning per tool call
Environment
- Claude Code 2.1.270
- macOS 26.6.2 (Darwin 25.6.0), arm64, zsh
- Hooks in user-level
~/.claude/settings.json, absolute command paths
Summary
A PreToolUse hook whose command file is not executable cannot be spawned. Claude Code reports
that as a non-blocking failure, so the tool call proceeds — including when the hook's entire
job is to return permissionDecision: "deny". The guard is off, nothing blocks, and the only
signal is a two-line warning printed once per tool call, forever:
PreToolUse:Bash hook error
Failed with non-blocking status code: /bin/sh: /path/to/guard.py: Permission denied
The message names /bin/sh and Permission denied but never the cause (chmod +x), so it reads
like a sandbox or TCC problem rather than a file mode.
Reproduction
guard.py — a deny-everything PreToolUse hook, deliberately not executable:
mkdir -p /tmp/hookrepro && cd /tmp/hookrepro
cat > guard.py <<'EOF'
#!/usr/bin/env python3
import json
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "DENIED BY GUARD"}}))
EOF
chmod 644 guard.py # <-- the whole bug
cat > settings.json <<'EOF'
{"hooks": {"PreToolUse": [{"matcher": "Bash",
"hooks": [{"type": "command", "command": "/tmp/hookrepro/guard.py"}]}]}}
EOF
claude -p "Run exactly one bash command: echo REPRO_MARKER. Then stop." \
--settings /tmp/hookrepro/settings.json --allowed-tools Bash \
--output-format stream-json --verbose < /dev/null
Observed — chmod 644 (not executable)
The command runs. The deny never happens:
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"...","type":"tool_result",
"content":"REPRO_MARKER","is_error":false}]}}
Expected, and what chmod 755 guard.py actually gives
{"type":"user","message":{"role":"user","content":[{"type":"tool_result",
"content":"DENIED BY GUARD","is_error":true,"tool_use_id":"..."}]}}
Same session, same settings, same prompt — the only difference is the file mode.
Two further gaps this exposes
- Nothing in
stream-json. In both runs the transcript carrieshook_started/
hook_response events for SessionStart and none at all for PreToolUse. In headless /
SDK use the failure is completely invisible: no event, no warning, no non-zero exit. The
interactive TUI's warning is the only place it surfaces.
- No deduplication. The warning is emitted per tool call. In the session where I hit this,
34 Bash calls produced 34 identical two-line blocks. Scrolled back it reads like a runaway
loop, which is how I noticed at all.
Why this is worth blocking on
Losing the executable bit is ordinary and silent: a file committed as 100644 and checked out on
a new machine, cp from a template, an editor that rewrites rather than edits in place, an
unzipped release, a rsync without -p. In my case the hook was committed 100644 once and the
guard it implements — a check that blocks a specific class of outward-facing text — was off for an
entire session while dutifully printing a warning nobody reads as "your guard is disabled".
A PreToolUse hook that can return deny is a safety control. A safety control that cannot be
executed should not fail open.
Suggested handling
- Treat a spawn failure of a
PreToolUsehook (EACCES / exit 126 / ENOENT) as blocking by
default, or require an explicit opt-in such as "failOpen": true per hook to keep today's
behaviour.
- Name the actual cause in the message:
hook command is not executable — chmod +x <path>. - Warn once per session per hook, not once per tool call.
- Report unrunnable hooks in
/doctorand mark them in/hooks. - Emit
hook_started/hook_responseforPreToolUsein--output-format stream-json.
Related
Same fail-open family, different trigger (deleted session cwd → posix_spawn ENOENT):
#67147, #65378, #76808. Also #88578 (Windows backslash paths → hook never executes, silently).
None of them covers a hook file that exists and is readable but lacks +x.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗