DEV Community

Yurukusa
Yurukusa

Posted on

Claude Code Ignores Its Own Tools. Here Are 3 Hooks That Force It to Behave.

I was reviewing GitHub Issues this week and noticed something odd: three of the most-reacted issues (186 reactions combined) are all the same underlying problem — Claude Code fighting its own design.

Claude has built-in tools (Read, Edit, Grep, Glob) that are faster and safer than bash equivalents. But it keeps reaching for sed, grep, and cat anyway. And that preference causes a cascade of problems.

Here are three hooks that fix it. Each is under 20 lines.

1. The Bash Addiction (Issue #19649, 48 reactions)

The problem: Claude uses sed -n '10,20p' instead of the Read tool. It runs grep -r "pattern" instead of the built-in Grep. It creates files with cat <<EOF instead of Write. Every one of these triggers an extra permission prompt that can't be cached.

Why it happens: LLM training data is full of bash one-liners. Claude defaults to what it "knows" from Stack Overflow, not what it has available.

The fix: A PreToolUse hook that intercepts Bash commands containing these patterns and denies them with a pointer to the correct built-in tool.

#!/bin/bash
# ~/.claude/hooks/enforce-builtin-tools.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
[ -z "$COMMAND" ] && exit 0

# Check all segments of piped/chained commands
while IFS= read -r segment; do
  cmd=$(echo "$segment" | sed 's/^[[:space:]]*//' | sed 's/^[A-Za-z_][A-Za-z_0-9]*=[^ ]* //')
  base=$(basename "$(echo "$cmd" | awk '{print $1}')" 2>/dev/null)
  case "$base" in
    cat)     msg="Use Read to read files, or Write to create them" ;;
    head|tail) msg="Use Read with offset/limit parameters" ;;
    sed)     msg="Use Edit for modifications, Read for line ranges" ;;
    grep|rg) msg="Use the built-in Grep tool" ;;
    find)    msg="Use the built-in Glob tool" ;;
    *)       continue ;;
  esac
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Do not use \`$base\`. $msg\"}}"
  exit 0
done < <(echo "$COMMAND" | tr '|' '\n' | sed 's/[;&]\{1,2\}/\n/g')
exit 0
Enter fullscreen mode Exit fullscreen mode

This catches grep even after a pipe (cmd | grep) or in chained commands.

2. The cd Trap (Issue #28240, 90 reactions)

The problem: Claude runs cd /some/dir && npm install. The permission prompt shows cd:* which can't be whitelisted. The actual dangerous command (npm install) is hidden behind the harmless cd.

This is the most-reacted Claude Code issue of this type — 90 reactions. The permission system evaluates the first command in the chain, not the one that matters.

The fix: A hook that blocks cd chaining and feeds back the real command, so Claude retries immediately without splitting into three separate tool calls.

#!/bin/bash
# ~/.claude/hooks/no-cd-chaining.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
[ -z "$COMMAND" ] && exit 0

if echo "$COMMAND" | grep -qE '^\s*(cd|pushd)\s+\S+\s*(&&|;|\|\|)'; then
  REAL_CMD=$(echo "$COMMAND" | sed -E 's/^\s*(cd|pushd)\s+("[^"]*"|'\''[^'\'']*'\''|[^ &;|]+)\s*(&&|;|\|\|)\s*//')
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Do not prefix with cd. Run directly: $REAL_CMD\"}}"
  exit 0
fi
exit 0
Enter fullscreen mode Exit fullscreen mode

Pair this with "permissions": { "allow": ["Bash(cd:*)"] } as a safety net. cd alone doesn't mutate anything.

3. Plan Mode Escape (Issue #14259, 48 reactions)

The problem: Claude exits plan mode and starts implementing before the plan is reviewed. There's no PrePlanMode or PostPlanMode hook event, so users can't control when planning transitions happen.

The fix: While dedicated plan mode events don't exist yet, PostToolUse + ExitPlanMode works:

#!/usr/bin/env bash
# ~/.claude/hooks/archive-plan.sh
# Archives the plan when Claude exits plan mode
PLAN_FILE=$(ls -t "${HOME}/.claude/plans"/*.md 2>/dev/null | head -1)
if [[ -n "$PLAN_FILE" && -f "$PLAN_FILE" ]]; then
    ARCHIVE="${CLAUDE_PROJECT_DIR:-.}/.plans/archive"
    mkdir -p "$ARCHIVE"
    cp "$PLAN_FILE" "$ARCHIVE/$(basename "$PLAN_FILE" .md)-$(date +%Y%m%d-%H%M%S).md"
fi
exit 0
Enter fullscreen mode Exit fullscreen mode
{
  "hooks": {
    "PostToolUse": [{
      "matcher": "ExitPlanMode",
      "hooks": [{"type": "command", "command": "bash ~/.claude/hooks/archive-plan.sh"}]
    }]
  }
}
Enter fullscreen mode Exit fullscreen mode

For blocking plan exit (not just archiving), use PermissionRequest + ExitPlanMode instead — exit code 2 blocks the transition, and your reason is sent back to Claude.

The Pattern

All three issues share one root cause: Claude Code's default behaviors conflict with its own architecture. It has built-in tools but prefers bash. It chains commands that break permissions. It exits plan mode without checkpoint.

The fix is always the same: a PreToolUse or PostToolUse hook that enforces the behavior the architecture intended.

These hooks are running in production right now — 700+ hours of autonomous Claude Code operation. The issues they solve are real, and 186 reactions prove a lot of people hit them.


Check if your setup has these gaps: npx cc-health-check — free, 20 checks, nothing leaves your machine.

📘 Production Guide: Hook Design & Autonomous Operation

Top comments (0)