---
title: "How a 30-Line Shell Hook Cut My Claude Code Permission Prompts by 95%"
description: "Two PreToolUse rules that reshape compound bash into the allowlist's native vocabulary. From 43 prompts to 1 in one session."
date: 2026-05-15
tags: ["ai", "claude-code", "agentic-workflows", "developer-tools", "engineering-culture", "automation"]
canonical: https://glenneggleton.com/blog/claude-code-hooks-cut-permission-prompts-95-percent
---
Forty-three Claude Code permission prompts in one work session. Then I added thirty lines of bash to `.claude/hooks/block-bad-bash.sh`. The next session: one prompt. Same project. Same kind of work. Same allowlist — I didn't touch it.

The lesson is not that Claude Code permission prompts are over-cautious. They are a shape-mismatch problem between what the agent emits and what your allowlist patterns can match. You cannot fix a shape mismatch by expanding the allowlist. You fix it by changing the agent's output shape. Two PreToolUse hook rules did the rewriting for me, and the friction dropped through the floor.

## The friction nobody mentions when they recommend Claude Code

I'd been running Claude Code daily on two real codebases for months: a multi-package monorepo and this Astro site. The allowlist in `.claude/settings.json` had grown to roughly eighty entries: `Bash(git:*)`, `Bash(cd:*)`, `Bash(git status *)`, every git verb spelled out, plus the usual file utilities. I'd added entries every time I saw a prompt I thought was unreasonable. The list kept growing. The prompt rate did not drop.

The prompts were arriving every ninety seconds or so on routine work. Most of them were on bash calls I'd already allowlisted at least twice in different forms. Each one stopped the agent mid-loop and forced me back to the terminal to approve or deny. The interruption cost was real: every prompt was a context switch, a re-read of the proposed command, a yes/no decision, a return to whatever I'd been thinking about. Multiply by forty and the session is over before the work is.

The reflex is to assume Claude is being too cautious. I had assumed that for weeks. The reflex is wrong.

## The matcher tokenizes per command. The agent emits compounds.

Here is what the allowlist actually does. When Claude proposes a Bash call, the harness checks the command string against the patterns in `permissions.allow`. If a pattern matches, the call goes through without a prompt. If nothing matches, you get the prompt.

The patterns match the *whole command string.* A pattern like `Bash(git status *)` matches `git status` and `git status -s`. It does not match `cd src && git status`. That compound is a different string. To the matcher it is one command, even though it contains two. You have not allowlisted the compound; you have allowlisted the individual verbs. The matcher doesn't care that the compound *would* be allowlisted if it were two separate calls.

This is where the loop runs away from you. Claude writes good bash. Claude writes the kind of bash a senior engineer writes at the prompt: chained, expressive, multi-step. `cd packages/web && pnpm test`. `git fetch origin && git rebase origin/main && git status`. `mkdir -p tmp && cd tmp && git clone <url>`. These are sensible compound commands. They are also, every single one of them, novel strings that don't match any pattern in your allowlist no matter how many `Bash(git:*)` variants you add. You can allowlist every git verb in existence and the matcher will still prompt on `cd src && git log` because the compound is its own unique tokenization.

The allowlist is not under-built. The allowlist is at the wrong altitude. It tokenizes by command. The agent emits by intent, and intent in bash is naturally compound.

I tried fighting this with more patterns. `Bash(cd * && git *)`. `Bash(* && git *)`. The variants you can dream up are infinite and the agent will find a shape outside every one of them within ten minutes. You cannot out-allowlist Claude's bash idiom. The shape mismatch is structural.

## The Claude Code hook reshapes the output before the matcher sees it

Claude Code has a PreToolUse hook system. Before the harness checks the allowlist, it runs whatever script you've wired up against the tool input. If the script exits 0, the call proceeds to the allowlist check. If the script exits 2, the call is blocked, and the script's stderr is fed back to the agent as feedback. The agent reads the feedback and tries again.

That second sentence is the unlock. The hook is a feedback channel — a way to say *"don't ship that shape; ship this one"* and have the agent comply on the next call. The agent rewrites because the feedback explains how to rewrite. The new shape clears the allowlist. The prompt never fires.

Two rules cover roughly 95% of the prompt-triggering compounds I was seeing.

Rule one: any `cd <path> && git ...` gets blocked with a stderr message telling the agent to use `git -C <path> ...` instead. Git has a flag for "operate on a repo at a different path," and it is allowlist-friendly because `git -C src status` matches `Bash(git:*)` and `Bash(git status *)` exactly the way the matcher wants. The agent was emitting the `cd && git` shape out of habit, not because it had to. Push it once, and it learns within two or three calls.

Rule two: any command with two or more `&&` operators (three or more chained commands) gets blocked with a message telling the agent to split them into separate Bash tool calls, and to issue independent ones in parallel in the same message. This rule does the heavy lifting. The agent's instinct to write `mkdir -p tmp && cd tmp && git clone <url>` becomes three separate Bash calls, each of which matches a simple allowlist pattern, and the independent ones get parallelized in a single message, which is actually faster than the chained version.

Here is the entire script. Thirty lines including the comment header.

```bash
#!/usr/bin/env bash
# PreToolUse hook on Bash: blocks two patterns that reliably trigger permission prompts.
#   1. `cd <path> && git ...`   — use `git -C <path> ...` instead
#   2. 3+ commands chained with `&&` — split into separate Bash calls (parallel where independent)
# Exits 2 with stderr message so Claude sees feedback and rewrites the call.

set -uo pipefail

cmd=$(jq -r '.tool_input.command // ""')

# Rule 1: `cd <subdir> && git ...`
if printf '%s' "$cmd" | grep -qE '^[[:space:]]*cd[[:space:]]+[^&]+&&[[:space:]]*git([[:space:]]|$)'; then
  {
    echo "Blocked by .claude/hooks/block-bad-bash.sh:"
    echo "  Pattern \`cd <path> && git ...\` triggers a permission prompt."
    echo "  Use \`git -C <path> <args>\` instead, and split into separate Bash calls (run independent ones in parallel)."
  } >&2
  exit 2
fi

# Rule 2: 3+ commands chained with `&&` (i.e. 2+ `&&` operators)
amp_count=$(printf '%s' "$cmd" | grep -oE '&&' | wc -l | tr -d '[:space:]' || echo 0)
if [ "${amp_count:-0}" -ge 2 ]; then
  {
    echo "Blocked by .claude/hooks/block-bad-bash.sh:"
    echo "  Long \`&&\` chain ($((amp_count + 1)) commands). Long chains trigger permission prompts."
    echo "  Split into separate Bash tool calls. Independent calls should be issued in parallel in a single message."
  } >&2
  exit 2
fi

exit 0
```

The wiring in `.claude/settings.json` is a four-line block under `hooks.PreToolUse`:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-bad-bash.sh"
          }
        ]
      }
    ]
  }
}
```

Make the script executable, drop the JSON block into your settings, restart the session. That's the whole install. Canonical copy lives in [a gist](https://gist.github.com/geggleto/f1274b18abf3b599bbc5f074c7bb8fac) if you want to pull it straight from the source.

## The numbers

One work session before the hook, normal mix of refactoring and git work across the two repos: forty-three git-related permission prompts. I counted because I was getting suspicious that the allowlist wasn't doing what I thought it was doing.

One work session after the hook, comparable mix of work, comparable wall-clock duration: one git-related permission prompt. The one that did fire was on a bash idiom neither hook rule covered (a backtick subshell), which is on me, not on the hook.

Forty-three to one is a 97% reduction on the exact count. I'm calling it 95% because rounding down keeps me honest about the sample. The sample is one developer over two sessions on two project codebases. It is not statistically rigorous; it is representative of the kind of session a senior engineer running Claude Code on real codebases is likely running today.

What this measurement doesn't say: if your allowlist is `Bash(*)`, you never see prompts in the first place and the hook saves you nothing. If your work is mostly file editing with little bash, the hook is a smaller win. If you've already retrained yourself to write `git -C path` and never compound chain, you're getting the benefit manually and the hook is automating a habit you've already built.

There's a second-order cost the headline doesn't capture. Every blocked call is one extra agent turn: feedback in, rewrite, retry. That's roughly a 2x cost on the blocked calls in agent compute. It's swamped by the friction savings, but it's not free. If you're running on a tight budget the math still works because the rewrites happen once per session. Claude learns the new shape within the first two or three blocks and stops emitting the old one.

## Why the allowlist can't catch up

There's a temptation to read this and conclude that the answer is a better allowlist pattern language. Wildcards across `&&`, regex matchers, something. I don't think that's the right read.

The deeper reason the hook works is that it operates at the right altitude. The allowlist is a *destination shape filter*: it says "commands in these shapes are pre-approved." It cannot say "commands not in these shapes should be rewritten into shapes that are." That second sentence is a translation, and translations need a programmable layer with feedback.

PreToolUse hooks are programmable. They have a feedback channel. They run before the allowlist check, so they get the first read on every tool call. They can encode arbitrary rewrite policy in shell, and they can teach the agent what to emit instead. The allowlist is a binary gate. The hook is a coaching layer.

Once you see the distinction, the workflow win generalizes. Anywhere your agent is reliably emitting one shape and you want another, a hook can rewrite the policy in minutes. You don't have to wait for the agent's RLHF to come around to your preferences. You don't have to write a clever system prompt that the agent will forget in a long session. You write the hook, you exit 2 with a clear message, the agent rewrites on the next call.

## What this generalizes to

The thirty lines above are specific to git compounds. The pattern is not.

I now have a small handful of PreToolUse hook rules across my projects, all in the same shape: detect a tool-input pattern that I keep hitting friction on, exit 2 with a stderr message that tells the agent exactly what to do differently, let the agent rewrite. Each rule took me less than ten minutes to write. Each one removed a class of friction I had been living with for weeks.

The thing engineers undervalue about hooks is that they are the cheapest way to shape agent behavior in a way that *persists across sessions*. System prompt edits get truncated. Memory entries get ignored if the agent doesn't read them. CLAUDE.md guidance is advisory. A PreToolUse hook is enforcement; the agent literally cannot ship the blocked shape because the harness will not run it. The feedback loop is tight enough that the agent learns within one session, and the rule is durable enough that the next session starts with the new behavior in force from call one.

For anyone running Claude Code seriously, the operational practice is: when a class of friction appears two or three times in a session, ask whether a hook can rewrite it. If yes, write the hook before the next session. The compounding effect on your daily friction budget is large. The compounding effect on the agent's output quality is also large, because every shape the hook rewrites is a shape that was costing both of you turns.

## The takeaway, in one line

Permission prompts in Claude Code are not telling you that the allowlist is too narrow. They are telling you that the agent's output shape doesn't match what the allowlist tokenizes for. Two narrow PreToolUse rules (`git -C` instead of `cd && git`, and split-on-long-chains) fix that mismatch by reshaping the output before the matcher ever sees it. Mine cut forty-three prompts to one. Yours will cut whatever your specific friction surface looks like, and the install is thirty lines plus a four-line settings block.

If you're running Claude Code on a real codebase and the prompt rate is wrecking your flow, write the hook. The friction you've been treating as the cost of doing business is a rewriting problem, and the rewriting is yours to install.

If you're a CTO or engineering lead trying to figure out which agent ergonomics actually matter for your team, [book a call](https://calendly.com/geggleto/30min). The friction surface is where AI engineering productivity is being won and lost right now, and it's almost entirely under your control.
