Description
PreToolUse hooks that exit with code 2 (deny) do not block tool execution in two scenarios:
-
Headless mode (-p / --print): Hooks fire (confirmed via file evidence logging) but the tool executes anyway. The exit-2 denial arrives after execution.
-
allowedTools: ["*"]: The hook pipeline is skipped entirely — hooks never fire. This makes it impossible to pre-approve all tools while still running enforcement hooks.
Expected Behavior
- In
-p mode: PreToolUse hooks should run synchronously before tool execution. Exit 2 should prevent the tool from running, and the {"reason":"..."} should be fed back to the agent so it can choose an alternative.
- With
allowedTools: ["*"]: Hooks should still fire. allowedTools should skip the user permission prompt, not the hook pipeline.
Actual Behavior
- In
-p mode: Tool executes before hook denial arrives (async race condition)
- With
allowedTools: ["*"]: Hooks are never called at all
Reproduction
Setup
// settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/tmp/test-block.sh",
"timeout": 5
}
]
}
]
},
"allowedTools": ["*"]
}
# /tmp/test-block.sh
#!/usr/bin/env bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
echo "HOOK FIRED" >> /tmp/hook-evidence.log
echo "{\"reason\":\"Blocked: use MCP tool instead of $COMMAND\"}" >&2
exit 2
Test 1: allowedTools wildcard
rm -f /tmp/hook-evidence.log
claude -p "run: echo hello"
cat /tmp/hook-evidence.log # → file does not exist (hook never fired)
Test 2: headless mode (without allowedTools wildcard)
// settings.json — change allowedTools to exclude Bash
{ "allowedTools": ["Read", "Edit", "Write", "Glob", "Grep"] }
rm -f /tmp/hook-evidence.log
claude -p "run: echo hello"
cat /tmp/hook-evidence.log # → "HOOK FIRED" (hook ran, but tool still executed)
Use Case
We run a governance framework (isaac) with PreToolUse:Bash hooks that enforce MCP-first tool usage — when an agent tries git status via Bash, the hook blocks it and directs the agent to use a fork-free MCP tool (git_status via dulwich). This is critical for preventing fork exhaustion (kern.maxprocperuid limit).
The current behavior makes it impossible to:
- Pre-approve all tools (
allowedTools: ["*"]) while keeping hook enforcement
- Run headless sessions (
-p) with hook enforcement
We need allowedTools to control user prompts and hooks to control agent behavior — independently.
Environment
🤖 Generated with Claude Code
Description
PreToolUse hooks that exit with code 2 (deny) do not block tool execution in two scenarios:
Headless mode (
-p/--print): Hooks fire (confirmed via file evidence logging) but the tool executes anyway. The exit-2 denial arrives after execution.allowedTools: ["*"]: The hook pipeline is skipped entirely — hooks never fire. This makes it impossible to pre-approve all tools while still running enforcement hooks.Expected Behavior
-pmode: PreToolUse hooks should run synchronously before tool execution. Exit 2 should prevent the tool from running, and the{"reason":"..."}should be fed back to the agent so it can choose an alternative.allowedTools: ["*"]: Hooks should still fire.allowedToolsshould skip the user permission prompt, not the hook pipeline.Actual Behavior
-pmode: Tool executes before hook denial arrives (async race condition)allowedTools: ["*"]: Hooks are never called at allReproduction
Setup
Test 1: allowedTools wildcard
Test 2: headless mode (without allowedTools wildcard)
Use Case
We run a governance framework (isaac) with PreToolUse:Bash hooks that enforce MCP-first tool usage — when an agent tries
git statusvia Bash, the hook blocks it and directs the agent to use a fork-free MCP tool (git_statusvia dulwich). This is critical for preventing fork exhaustion (kern.maxprocperuidlimit).The current behavior makes it impossible to:
allowedTools: ["*"]) while keeping hook enforcement-p) with hook enforcementWe need
allowedToolsto control user prompts and hooks to control agent behavior — independently.Environment
🤖 Generated with Claude Code