[BUG] PreToolUse hook without the executable bit fails open: deny guards silently stop guarding, one Permission denied warning per tool call

Status Open
Reported on v2.1.270
Maintainer reply None cached
Activity 1 comment · opened Sep 14, 2026

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

  1. Nothing in stream-json. In both runs the transcript carries hook_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.

  1. 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 PreToolUse hook (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 /doctor and mark them in /hooks.
  • Emit hook_started / hook_response for PreToolUse in --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.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗