You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Your team has a hard rule — never force-push to main. Where does it go so the agent
does not walk around it?
There are three places to put it and they fail differently. I ran all three on one machine
and measured what each one actually catches. Short answer first, then the numbers.
The short answer
Use all three, for different jobs:
// 1. CLAUDE.md — changes what the model decides to do// Plain prose. Measured: 0 violations in 22 runs with the rule present,// 17 in 17 without it. This is the cheapest thing that works.// 2. .claude/settings.json — permissions.deny — a hard stop on a static path
{
"permissions": {
"deny": ["Edit(./infra/**)", "Read(./.env)"]
}
}
// Reaches inside Bash: `sed -i` and `>` redirection to a denied path are both stopped.// It is per-path, not per-directory-intent: deny Edit(./secret.txt) and a sibling file// still writes.// 3. A PreToolUse hook — for anything that needs a condition
And the hook, with the one line most people get wrong:
No ^. The pattern matches the verb anywhere in the command. This is the whole
ballgame — see the measurement below.
(-C|-c)\s+\S+\s+ before push.git -C ~/repo push --force is a force-push and
slips past a pattern that expects git push to be adjacent. I did not put that arm in
from theory. I checked it while writing this and found the same assumption in three guards
I had already shipped: git -C /repo push --force, git -C /repo add .env and git -C /repo reset --hard all returned exit 0, which is an approval, not a shrug.
Twelve variants in total, listed with before/after exit codes in Close the git-global-options bypass: one -C walked past all three core guards #1107. One of the three
needed the fix in two places: repairing the checks changed nothing, because a pre-filter
one layer up counted git verbs in the raw text, scored git -C /repo reset --hard as
zero verbs, and exited before any check ran. If you write guards, that is the part worth
copying — after you fix a pattern, go looking for the same shape one layer above it.
exit 1 when jq is missing, not exit 0. A hook that exits 0 approves the command,
and its stderr goes to the debug log only — never to the operator's screen. A non-zero,
non-2 exit still lets the action proceed but surfaces the first line of stderr, so an
inactive guard says so out loud instead of silently allowing everything.
[^A-Za-z0-9] rather than \s in front of -f and +branch, and --force\b
rather than --force(\s|$). I wrote this snippet with \s first and then ran it against
the obvious variants before posting. Six got through, all the same shape: the token was
quoted. git push origin "+main", git push "--force", git push origin main '-f' — the
shell strips the quotes before git ever sees them, so all three are force-pushes, and all
three walked past a pattern that demanded whitespace immediately before the token. The
version above blocks 20 variants and leaves 18 ordinary pushes alone, including --follow-tags, --signed=if-asked and a branch called my-branch-of. --force\b also
covers --force-with-lease and --force-if-includes, so they no longer need their own arm.
And one it still misses, found today in the same sweep: a line continuation.
git push \
--force origin feature
The shell joins those two lines before git sees them, but grep is line-oriented, so the
pattern is offered git push \ and --force origin feature separately and matches
neither. 0.9% of the Bash calls counted below use a continuation — that is how long commands
get typed, not a trick. Fixed in my own guards in #1108; the fix is to join
backslash+newline before matching, which is one
parameter expansion in a script file (CMD=${CMD//\\$'\n'/ }) and genuinely awkward to write inside a single-quoted bash -c.
That is the real argument for keeping a guard in a file rather than inline in settings.json: not tidiness, but room to normalise the input before you match on it.
Why the ^ matters — 43,754 commands from my own history
Anchoring at the start of the string is the natural way to write a guard, because the
hook's first job is to exit early on commands that are none of its business:
Then I counted what my agent actually types. Every transcript under ~/.claude/projects
— 578 files, 764 MB, 2026-07-01 to 2026-09-03, parsed for tool_use blocks with name: "Bash", counted as requested, which is the moment a PreToolUse hook runs:
Bash tool calls
43,754
contain a separator (&&, ;, ||, |)
89.3%
begin with cd
32.1%
Against the two guards I had installed this morning, patterns copied verbatim out of the
scripts that were running at the time (both since fixed — same day, #1107):
hook
pattern
commands containing the verb
matched
never seen
force-push guard
^\s*git\s+push
483
52
89.2%
secret guard
^\s*git\s+add
1,697
509
70.0%
Both guards were registered, executable and correct about what they refuse. They were
simply not consulted about most of what they exist to refuse, because cd repo && git push --force does not begin with git push.
Those are this morning's figures. The corpus grows every day, and a day spent typing
force-push variants at a guard pushes the git push population up by a few dozen on its
own — over the course of today the never-seen share moved from 89.2% to 89.5%. The ratio
is stable; the counts are not.
Run it against your own history — reads local files only, prints nothing about their contents:
python3 - <<'PY'import json, os, reROOT = os.path.expanduser("~/.claude/projects")# grep is line-oriented: ^ anchors to a LINE, not to the command. Emulate that.SEEN = re.compile(r"^[ \t]*git[ \t]+push", re.MULTILINE)HAS = re.compile(r"git\s+push")cmds = []for dirpath, _d, names in os.walk(ROOT): # walk: subagent transcripts live deeper for nm in names: if not nm.endswith(".jsonl"): continue for line in open(os.path.join(dirpath, nm), encoding="utf-8", errors="replace"): if '"tool_use"' not in line: continue try: d = json.loads(line) except Exception: continue for b in (d.get("message") or {}).get("content") or []: if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("name") == "Bash": c = (b.get("input") or {}).get("command") if isinstance(c, str) and c.strip(): cmds.append(c)pop = [c for c in cmds if HAS.search(c)]seen = [c for c in pop if SEEN.search(c)]print(f"{len(cmds):,} Bash calls; {len(pop)} contain 'git push'; " f"{len(seen)} would match ^\\s*git\\s+push; " f"{(len(pop)-len(seen))/max(len(pop),1)*100:.1f}% never seen")PY
What surprised me: the weakest-looking place was the strongest
I have been telling people to use hooks because "CLAUDE.md gets ignored." Then I tested it.
Thirty-nine runs between 2026-08-29 and 08-31, two task shapes (a string replacement and
an irreversible delete), one model, with and without a prohibition written in CLAUDE.md: 0 violations in 22 runs with the rule, 17 in 17 without it.
Rule length, position in the file, competing instructions and how dangerous the action was
made no measurable difference. Nine English-language runs behaved the same as the Japanese
ones. (The permissions.deny behaviour above — that it reaches sed -i and > — is from a
separate run on 2026-08-31, written up on anthropics/claude-code#89251.)
Do not read that as "prose is enough". Zero out of twenty-two has a 95% upper bound of 12.7% — it means "not often", not "never", and a rule you cannot afford to have broken
once needs a mechanism, not a sentence. But it does mean the cheapest layer is doing real
work, and a guard that never sees the traffic is doing less than the sentence.
So where does the rule live?
what you are protecting against
where it goes
the model choosing to do it
CLAUDE.md. Cheapest, measurably effective, not a guarantee
a specific static path
permissions.deny — it reads inside Bash commands, including sed -i and >
anything conditional
a PreToolUse hook — but match anywhere, not at ^
any process at all, not just the agent
OS sandbox. The permissions documentation says so directly: path rules do not cover other processes
this specific rule, with no way around it at all
the server. Branch protection on the remote refuses the force-push no matter who sent it, and it is the only one of these four that keeps working when the command comes from a terminal you are not watching
Two more things that cost me time and are cheap to avoid:
Register file guards on Bash too. A hook on Read|Edit|Write reads .tool_input.file_path, which does not exist on a Bash call — so it sees an empty value,
matches nothing and exits 0, which is an affirmative approval.
Subagent transcripts live one directory deeper (<session>/subagents/*.jsonl, and they
nest). Hooks fire on a subagent's Bash calls, so if you are auditing your own history,
walk recursively — 276 of my 578 transcript files were subagent ones, 48% of the corpus.
The free hooks I run are MIT and in this repo; everything above is reproducible without them.
Happy to compare numbers if you run the snippet — I am most interested in whether the
compound-command share holds outside my own unattended setup.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Your team has a hard rule — never force-push to
main. Where does it go so the agentdoes not walk around it?
There are three places to put it and they fail differently. I ran all three on one machine
and measured what each one actually catches. Short answer first, then the numbers.
The short answer
Use all three, for different jobs:
And the hook, with the one line most people get wrong:
{ "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "bash -c 'INPUT=$(cat); command -v jq >/dev/null 2>&1 || { echo \"GUARD INACTIVE: jq not found\" >&2; exit 1; }; CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command // empty\"); [ -z \"$CMD\" ] && exit 0; if echo \"$CMD\" | grep -qE \"\\bgit\\s+((-C|-c)\\s+\\S+\\s+|--git-dir=\\S+\\s+)*push\\b[^;|&]*(--force\\b|[^A-Za-z0-9]-[a-zA-Z]*f\\b|[^A-Za-z0-9]\\+[A-Za-z0-9._/-]+)\"; then echo \"BLOCKED: force push rewrites the remote branch\" >&2; exit 2; fi; exit 0'" }] }] } }Four details in there that are not decoration:
^. The pattern matches the verb anywhere in the command. This is the wholeballgame — see the measurement below.
(-C|-c)\s+\S+\s+beforepush.git -C ~/repo push --forceis a force-push andslips past a pattern that expects
git pushto be adjacent. I did not put that arm infrom theory. I checked it while writing this and found the same assumption in three guards
I had already shipped:
git -C /repo push --force,git -C /repo add .envandgit -C /repo reset --hardall returned exit 0, which is an approval, not a shrug.Twelve variants in total, listed with before/after exit codes in Close the git-global-options bypass: one
-Cwalked past all three core guards #1107. One of the threeneeded the fix in two places: repairing the checks changed nothing, because a pre-filter
one layer up counted git verbs in the raw text, scored
git -C /repo reset --hardaszero verbs, and exited before any check ran. If you write guards, that is the part worth
copying — after you fix a pattern, go looking for the same shape one layer above it.
exit 1whenjqis missing, notexit 0. A hook that exits 0 approves the command,and its stderr goes to the debug log only — never to the operator's screen. A non-zero,
non-2 exit still lets the action proceed but surfaces the first line of stderr, so an
inactive guard says so out loud instead of silently allowing everything.
[^A-Za-z0-9]rather than\sin front of-fand+branch, and--force\brather than
--force(\s|$). I wrote this snippet with\sfirst and then ran it againstthe obvious variants before posting. Six got through, all the same shape: the token was
quoted.
git push origin "+main",git push "--force",git push origin main '-f'— theshell strips the quotes before
gitever sees them, so all three are force-pushes, and allthree walked past a pattern that demanded whitespace immediately before the token. The
version above blocks 20 variants and leaves 18 ordinary pushes alone, including
--follow-tags,--signed=if-askedand a branch calledmy-branch-of.--force\balsocovers
--force-with-leaseand--force-if-includes, so they no longer need their own arm.And one it still misses, found today in the same sweep: a line continuation.
The shell joins those two lines before
gitsees them, butgrepis line-oriented, so thepattern is offered
git push \and--force origin featureseparately and matchesneither. 0.9% of the Bash calls counted below use a continuation — that is how long commands
get typed, not a trick. Fixed in my own guards in #1108; the fix is to join
backslash+newline before matching, which is one
parameter expansion in a script file (
CMD=${CMD//\\$'\n'/ }) and genuinely awkward to write inside a single-quotedbash -c.That is the real argument for keeping a guard in a file rather than inline in
settings.json: not tidiness, but room to normalise the input before you match on it.Why the
^matters — 43,754 commands from my own historyAnchoring at the start of the string is the natural way to write a guard, because the
hook's first job is to exit early on commands that are none of its business:
Then I counted what my agent actually types. Every transcript under
~/.claude/projects— 578 files, 764 MB, 2026-07-01 to 2026-09-03, parsed for
tool_useblocks withname: "Bash", counted as requested, which is the moment a PreToolUse hook runs:&&,;,||,|)cdAgainst the two guards I had installed this morning, patterns copied verbatim out of the
scripts that were running at the time (both since fixed — same day, #1107):
^\s*git\s+push^\s*git\s+addBoth guards were registered, executable and correct about what they refuse. They were
simply not consulted about most of what they exist to refuse, because
cd repo && git push --forcedoes not begin withgit push.Those are this morning's figures. The corpus grows every day, and a day spent typing
force-push variants at a guard pushes the
git pushpopulation up by a few dozen on itsown — over the course of today the never-seen share moved from 89.2% to 89.5%. The ratio
is stable; the counts are not.
Run it against your own history — reads local files only, prints nothing about their contents:
What surprised me: the weakest-looking place was the strongest
I have been telling people to use hooks because "CLAUDE.md gets ignored." Then I tested it.
Thirty-nine runs between 2026-08-29 and 08-31, two task shapes (a string replacement and
an irreversible delete), one model, with and without a prohibition written in
CLAUDE.md: 0 violations in 22 runs with the rule, 17 in 17 without it.Rule length, position in the file, competing instructions and how dangerous the action was
made no measurable difference. Nine English-language runs behaved the same as the Japanese
ones. (The
permissions.denybehaviour above — that it reachessed -iand>— is from aseparate run on 2026-08-31, written up on
anthropics/claude-code#89251.)
Do not read that as "prose is enough". Zero out of twenty-two has a 95% upper bound of
12.7% — it means "not often", not "never", and a rule you cannot afford to have broken
once needs a mechanism, not a sentence. But it does mean the cheapest layer is doing real
work, and a guard that never sees the traffic is doing less than the sentence.
So where does the rule live?
CLAUDE.md. Cheapest, measurably effective, not a guaranteepermissions.deny— it reads inside Bash commands, includingsed -iand>PreToolUsehook — but match anywhere, not at^Two more things that cost me time and are cheap to avoid:
Bashtoo. A hook onRead|Edit|Writereads.tool_input.file_path, which does not exist on a Bash call — so it sees an empty value,matches nothing and exits 0, which is an affirmative approval.
<session>/subagents/*.jsonl, and theynest). Hooks fire on a subagent's Bash calls, so if you are auditing your own history,
walk recursively — 276 of my 578 transcript files were subagent ones, 48% of the corpus.
The free hooks I run are MIT and in this repo; everything above is reproducible without them.
Happy to compare numbers if you run the snippet — I am most interested in whether the
compound-command share holds outside my own unattended setup.
All reactions