TL;DR: I run Claude Code in auto mode, so it rarely asks before running commands. For the few commands that can’t be undone (git push, opening PRs, commenting on issues, changing production config), a short PreToolUse hook blocks the first attempt and tells Claude to review before retrying. Over four weeks it fired 810 times. About one block in five, Claude skipped the review and just retried. The reviews that did happen mostly caught text that didn’t match the code, not bugs in the code. Script, settings, stats and known bypasses are below.

The hook, the stats script and example config are on GitHub: claude-code-advisor-gate.


I work in IT at a company. Most of our internal tools — chat bots, approval flows, monitoring probes, account cleanup jobs — I’ve built together with Claude Code. To keep things moving I run it in auto mode, so most commands don’t pop a confirmation prompt.

But a handful of commands can’t be taken back:

  • git push lands on a branch that CI deploys to production.
  • gh pr create and gh issue comment notify coworkers the moment they run.
  • ssh to a production box to edit .env or run systemctl restart changes the live service immediately.

A bad local edit is one git checkout away from undone. These hit other people.

Why not just use an ask rule?

Claude Code permission rules can already mark commands as ask. I didn’t want that, for two reasons:

  • ask needs me at the keyboard. I often hand Claude a long task and go work on something else. A pending prompt stalls the whole run.
  • Asking me isn’t reviewing. The prompt shows one line, git push origin main. It doesn’t show what changed or whether the tests passed. In practice I’d click “allow” on reflex.

What I wanted: before anything leaves the machine, Claude reviews the change, the test evidence and the risks itself. If I’m around, I can read that review. If I’m not, at least nothing goes out unexamined.

Design: the hook forces the pause, CLAUDE.md says what to do with it

Layer Mechanism Job Enforced by
Hard PreToolUse hook Always block the first attempt at a risky command and say why Claude Code itself; the model can’t override the result
Soft One rule in CLAUDE.md After a block, actually do the review instead of retrying blindly The model following instructions

Two layers, because the hook is just a shell script. It knows how many times a command has been attempted. It can’t see what Claude did between attempts, so it can’t verify that a review happened. The data below shows that gap is real.

The hook

Save it as ~/.claude/hooks/advisor-gate.sh and chmod +x it. Dependencies: jq and shasum.

#!/bin/bash
# PreToolUse(Bash) advisor gate: the first time an outward-facing command runs (open/merge a PR,
# push, file or comment on an issue, change production config), block it and ask for a review.
# Retrying the exact same command in the same session lets it through. The hook cannot verify
# that a review actually happened; it only guarantees a pause before anything leaves the machine.
# Markers are keyed by a hash of session + command and cleaned up after a day.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
[ -z "$cmd" ] && exit 0

pattern='(^|[;&|[:space:]])(gh[[:space:]]+pr[[:space:]]+(create|merge)|gh[[:space:]]+issue[[:space:]]+(create|comment)|git[[:space:]]+push)([[:space:]]|$)'
prod='ssh[^|;&]*<your-prod-host>'
prod_write='(>>?[[:space:]]*[^[:space:]]*\.env|sed[[:space:]]+-i[^|;&]*\.env|cp[[:space:]][^|;&]*\.env|systemctl[[:space:]]+restart)'

hit=""
if printf '%s' "$cmd" | grep -Eq "$pattern"; then
  hit="an outward-facing git/gh operation"
elif printf '%s' "$cmd" | grep -Eq "$prod" && printf '%s' "$cmd" | grep -Eq "$prod_write"; then
  hit="a production config change or service restart"
fi
[ -z "$hit" ] && exit 0

session=$(printf '%s' "$input" | jq -r '.session_id // "nosession"')
dir="$HOME/.claude/advisor-gate"
mkdir -p "$dir"
find "$dir" -type f -mtime +1 -delete 2>/dev/null
key=$(printf '%s\n%s' "$session" "$cmd" | shasum -a 256 | cut -c1-32)
marker="$dir/$key"

if [ -f "$marker" ]; then
  rm -f "$marker"
  exit 0
fi
touch "$marker"

reason="Advisor gate: this command is ${hit}. Before running it, call the advisor to review the change, the test evidence and the risks. If you still want to run it after the review, retry the exact same command and it will go through. If the review finds a problem, fix it first."
jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
exit 0

How it works:

  1. Read the command. Before each tool call, Claude Code sends the hook a JSON payload on stdin. The Bash command is in tool_input.command; the session ID is in session_id.
  2. Decide if it’s risky. Two kinds of commands match:
    • Outward-facing git/gh: git push, gh pr create/merge, gh issue create/comment. The leading (^|[;&|[:space:]]) catches chained commands like npm test && git push.
    • Production changes: the command has to ssh to the prod host and write .env (redirect, sed -i, cp) or run systemctl restart. Plain ssh prod tail app.log passes through. Gate every ssh and you’ll rip the hook out within a week of debugging.
  3. Block once, let an identical retry through. A sha256 of session ID + full command text names a marker file. No marker: first attempt, create the marker, deny. Marker present: Claude was blocked and retried verbatim, so delete the marker and allow. Nobody has to click anything. If the review changes the command, even slightly (say it adds --force-with-lease), the hash changes and the new command gets gated again. A changed command deserves another look.
  4. Deny with instructions. The hook prints permissionDecision: "deny" with a permissionDecisionReason. Per the hooks docs, Claude Code blocks the call and shows Claude the reason. So the reason isn’t an error message for a human, it’s the model’s next instruction: what to review, how to proceed, what to do if the review finds a problem.

Wiring it up

In ~/.claude/settings.json, so it applies to every project:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/advisor-gate.sh",
            "timeout": 10,
            "statusMessage": "Advisor gate"
          }
        ]
      }
    ]
  }
}

And the soft half, in ~/.claude/CLAUDE.md:

# Advisor gate

`~/.claude/hooks/advisor-gate.sh` blocks the first attempt at outward-facing commands
(`gh pr create/merge`, `git push`, `gh issue create/comment`, ssh to prod that edits `.env`
or runs `systemctl restart`). When blocked, actually call the advisor to review the change,
the test evidence and the risks, then retry the identical command. Never retry without reviewing.

The last sentence matters most. Without it, the model tends to read the denial as a transient error and simply run the command again.

No advisor tool? My Claude Code setup has an advisor tool that hands the whole conversation to a stronger model for review. If yours doesn’t, replace “call the advisor” in the reason and in CLAUDE.md with either of these:

  • spin up a read-only reviewer subagent that reads git diff and the test output and says whether it’s safe to ship;
  • or, simplest: write three lines — what changed, test results, risks — before retrying.

Who does the review matters less than the fact that there is always a review step before anything goes out.

Four weeks of data

I wrote a script that walks my own Claude Code transcripts from September 28 to October 24. It’s a rough count from one person’s logs, not a controlled experiment. The script is in the appendix.

How often it fired: 810 blocks across 23 active days, about 35 a day.

Command type Blocks
git push 347
gh pr create/merge 303
gh issue create/comment 124
Production config change / restart 36

What happened after a block:

Outcome Count Share
Called the advisor after the block, then proceeded 501 62%
Had just reviewed right before; got blocked anyway 129 16%
No review anywhere nearby; just retried 180 22%

The last row is the one to look at: roughly one block in five, Claude ignored the CLAUDE.md rule and retried without reviewing. All 180 of those happened in sessions that did call the advisor at other points, so the tool was available. The model just didn’t use it there. This is the gap from the design section: the hook can force a pause, but it can’t control what happens during the pause. The middle row is the opposite cost: Claude had already reviewed on its own, and the gate stopped it again anyway.

What the reviews actually caught. The advisor’s output is encrypted in the transcripts, so I can’t read it. I can see what Claude did right after. Going through those, the most common fix wasn’t to code logic. It was to text that people would read:

  • A PR description said that when an external API returns an empty array, employees see “no results”. After the review, Claude checked the code: employees actually see “unrecognized response format”. The description was fixed before pushing.
  • A button’s status message: when two cancel clicks both hit a race, the UI said “submitting…” even though nothing was being submitted. After the review it was changed to “not cancelled, please try again”, the checks were re-run, then it was pushed.
  • A summary message for a team chat said “results below”, but when a quota ran out, not every group would actually be listed below. The wording was narrowed to the groups that would really get a card.
  • Before restarting a production service, Claude compared the service’s last start time with the .env file’s modification time. The service had started after the previous .env edit, so the restart would pick up only this one change and not some older, unverified edit. Only then did it restart.

None of these is dramatic on its own. But people take PR descriptions, UI messages and chat posts at face value, and a restart that quietly activates a forgotten config change is miserable to debug.

What it doesn’t catch

I tested a few variations. All of these go straight through:

Command Why it slips past
git -C ~/repo push An argument sits between git and push
bash -c "git push" git is preceded by a quote, not a separator the regex knows
/usr/bin/git push git is preceded by /
(cd repo && git push) push is followed by )
make release The push is inside a script; the hook only sees the command line

And in the other direction, git push --dry-run gets blocked. Harmless; one retry and it goes through.

So to be clear: this is a speed bump, not a security boundary.

  • It’s meant for an agent that follows rules but moves fast. Against deliberate evasion, you’ll never finish patching the regex.
  • Real boundaries belong elsewhere: branch protection, required CI before merge, least-privilege accounts on production hosts. None of those depend on the model’s good behavior.
  • You could loosen the regex to “git, any arguments, then push”, at the cost of more false positives. I’d rather miss rare spellings than block things all day. Once false positives pile up, people turn the hook off, and that’s the biggest hole of all.

Three more details:

  • The hook is just a file. The script and settings.json are ordinary files, and in auto mode Claude can technically edit them. Keep them in a dotfiles repo so any change shows up in a diff, or add “don’t modify the hooks directory or settings.json without asking” to CLAUDE.md.
  • Markers are keyed only by session + command. If Claude gets blocked and then takes a different route instead of retrying, the marker stays until the next day’s cleanup. Until then, the identical command in the same session will pass without a block.
  • Get the prod host right. If you ssh through an alias, add the alias to the prod regex too.

Takeaways

  • For irreversible actions, put a pause in front that always happens. Give that job to a hook, which the model can’t override.
  • Put what to do during the pause in CLAUDE.md, and accept that it won’t be followed every time. In my logs it was skipped about one time in five.
  • A denial reason is an instruction to the model, not an error message for a human.
  • What reviews catch most often is text that people will read and that doesn’t match the facts.
  • Be honest about what the gate misses, and keep the real security boundary in your git host and server permissions.

Appendix: the stats script

To see what your own gate has been doing, run this. It only reads transcripts under ~/.claude/projects. Pass the start of your own denial reason as the first argument if it differs from the default.

"""Count advisor-gate blocks in Claude Code transcripts and what happened after each one.

Usage: python3 -I gate_stats.py ["<start of your deny reason>"]
Reads ~/.claude/projects/*/*.jsonl (read-only).
Buckets:
  after          = advisor called within 60 events after the block
  before         = advisor called within 12 events before the block
  none_with_tool = neither, in a session that did use the advisor elsewhere
  none_no_tool   = neither, in a session that never called the advisor
"""
import collections, glob, json, os, sys

MARK = sys.argv[1] if len(sys.argv) > 1 else "Advisor gate: this command"
stats = collections.Counter()
kinds = collections.Counter()
days, seen = set(), set()

for f in glob.glob(os.path.expanduser("~/.claude/projects/*/*.jsonl")):
    ev = []
    for line in open(f, encoding="utf-8", errors="ignore"):
        try:
            e = json.loads(line)
        except ValueError:
            continue
        c = (e.get("message") or {}).get("content")
        if isinstance(c, list):
            ev += [(b, e.get("timestamp")) for b in c if isinstance(b, dict)]
    has_advisor = any(b.get("name") == "advisor" for b, _ in ev)
    uses = {b["id"]: b for b, _ in ev if b.get("type") == "tool_use"}
    for i, (b, ts) in enumerate(ev):
        text = json.dumps(b.get("content"), ensure_ascii=False)
        # Real blocks start with "PreToolUse:Bash hook error"; this skips pasted scripts and test output
        if b.get("type") != "tool_result" or "PreToolUse:Bash hook error" not in text[:40] or MARK not in text:
            continue
        if b["tool_use_id"] in seen:
            continue
        seen.add(b["tool_use_id"])
        days.add((ts or "")[:10])
        cmd = (uses.get(b["tool_use_id"], {}).get("input") or {}).get("command", "")
        kinds["push" if "git push" in cmd else "pr" if "gh pr" in cmd
              else "issue" if "gh issue" in cmd else "prod"] += 1
        after = False
        for x, _ in ev[i + 1:i + 60]:
            if x.get("name") == "advisor":
                after = True
            if x.get("type") == "tool_use" and x.get("name") == "Bash" \
                    and (x.get("input") or {}).get("command") == cmd:
                break
        before = any(x.get("name") == "advisor" for x, _ in ev[max(0, i - 12):i])
        if after:
            stats["after"] += 1
        elif before:
            stats["before"] += 1
        else:
            stats["none_with_tool" if has_advisor else "none_no_tool"] += 1

print("blocks", len(seen), "days", len(days - {""}))
print("by type", dict(kinds))
print("review", dict(stats))