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

Open
#94,362 1 comment 0 reactions 0 assignees View on GitHub
area:hooks area:security bug has repro platform:macos
Dominant language
Python
Stars
145k
Forks
23.1k
PR merge metrics
PR metrics pending

Description

## 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`.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start at the PreToolUse hook spawn and non-blocking error handling, then trace the stream-json hook event path and the /doctor and /hooks entry points mentioned in the report. Reproduce with the provided guard.py at chmod 644 and compare it with chmod 755. Done should make the failure behavior explicit and ensure the selected hook diagnostics are consistent without repeating warnings for every tool call.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, shell
Domain
cli, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.