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 pushlands on a branch that CI deploys to production.gh pr createandgh issue commentnotify coworkers the moment they run.sshto a production box to edit.envor runsystemctl restartchanges 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:
askneeds 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:
- 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 insession_id. - 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 likenpm test && git push. - Production changes: the command has to ssh to the prod host and write
.env(redirect,sed -i,cp) or runsystemctl restart. Plainssh prod tail app.logpasses through. Gate every ssh and you’ll rip the hook out within a week of debugging.
- Outward-facing git/gh:
- 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. - Deny with instructions. The hook prints
permissionDecision: "deny"with apermissionDecisionReason. 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 diffand 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
.envfile’s modification time. The service had started after the previous.envedit, 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, thenpush”, 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.jsonare 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
prodregex 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))