anthropics / anthropics/claude-code
[BUG] PreToolUse hook without the executable bit fails open: deny guards silently stop guarding, one Permission denied warning per tool call
- Vorherrschende Sprache
- Python
- Sterne
- 145k
- Forks
- 23.1k
- PR-Merge-Kennzahlen
- PR-Kennzahlen ausstehend
Beschreibung
## 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:
```bash
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:
```json
{"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
```json
{"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.
2. **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 `.
- 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`.
Beitragsleitfaden
Für dieses Repository ist kein Beitragsleitfaden indexiert
Bewertung
Dieses Issue wurde noch nicht bewertet.