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

Abierto
#94,362 1 comentario 0 reacciones 0 asignados Ver en GitHub
area:hooks area:security bug has repro platform:macos
Lenguaje dominante
Python
Estrellas
145k
Forks
23.1k
Métricas de merge de PR
Métricas de PR pendientes

Descripción

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

Guía de contribución

No hay ninguna guía de contribución indexada para este repositorio

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.