Este artículo aún no está disponible en tu idioma. Mostrando versión en inglés.
Claude Code Hooks on a Real Repo: What Broke and the Rules That Came Out of It
Guardrails for Claude Code on my own site repo: a PreToolUse merge hook, 40 bypass tests, a gate that could approve itself, and what each failure taught me.
The repo behind this site has had Claude Code working in it since late 2025. Most of the time the agent writes blog posts, bumps dependencies and edits YAML. Some of the time it merges pull requests and deploys. That second part is where the guardrails live. This is the log of how they got there: what broke, why, what rule now exists, and what I would do differently. Every number below is from a commit message, a test file or a script still in the tree.
The rule that existed only as text
On 2025-12-14 I added rule 3 to CLAUDE.md: “NEVER merge PRs without codex approval”. The rule count went from five to six, and the gate was a sentence. Nothing checked it.
For eight months nothing visibly went wrong. Then on 2026-08-30 I noticed that the autopilot loop could run a raw gh pr merge and skip the documented merge script entirely. The commit that fixed it says so: the gate “was documented but had no technical enforcement here”.
The fix was a PreToolUse hook on the Bash tool. Every shell command the agent wants to run passes through .claude/hooks/block-direct-merge.sh first, and the hook answers with a JSON decision:
block() {
printf '{"decision":"block","reason":"%s"}\n' "$1"
exit 0
}
The rule I took from this: a sentence in CLAUDE.md is a request. A hook is a rule. Hooks are cheap, so the only question is which actions deserve one. For me that is anything that changes what is live: merging, deploying, and the few files that control headers and CI.
What broke in the hook itself
The first version of the hook matched the phrase gh pr merge in the command string. Code review on that PR (#160) found that this was not enough, and the test file still carries the list of what slipped through.
Newlines. Bash treats a backslash-newline as a space. A line-based grep does not. So gh pr \<newline>merge 123 executed as a merge and matched nothing. The hook now flattens the command before matching:
COMMAND_FLAT=$(printf '%s' "$COMMAND" \
| sed -E 's/\$\{IFS\}|\$IFS/ /g' \
| tr -d '\\' \
| tr '\n\t\r' ' ' \
| tr -s ' ')
Word splitting and substitution. The second review pass verified live that gh${IFS}pr${IFS}merge 123 and gh $(echo pr) merge 123 both returned approve and both ran a real merge. The IFS case is handled by the sed above. The substitution case is handled by a second heuristic that does not try to resolve what the substitution expands to. It treats $(, backticks, eval or IFS next to the standalone words gh and merge as suspicious and blocks:
if echo "$COMMAND_FLAT" | grep -qE '\$\(|`|\bIFS\b|\beval\b'; then
if echo "$COMMAND_FLAT" | grep -qE '\bgh\b' && echo "$COMMAND_FLAT" | grep -qE '\bmerge\b'; then
block "Possible obfuscated \`gh pr merge\` invocation blocked: ..."
fi
fi
Malformed input. The original hook ran under set -euo pipefail. Empty or non-JSON stdin made it crash with no output, which the harness reads as no decision. The fix dropped set -e and added two explicit fail-closed branches.
The asymmetric word boundary. The regex had \b after merge but not before gh, so echo "though pr merge is risky" was blocked. Small, but this is the kind of false positive that breeds --no-verify habits.
eval 'gh pr merge 500' and bash -c 'gh pr merge 600' were in the test list from the start: the matcher runs on the raw command string, so a quoted phrase still contains the phrase. The hook’s header states the limit: “a static regex gate on the pre-execution command string, not a shell parser or a sandbox. It cannot be made bypass-proof against arbitrary shell”.
The test file grew from 22 cases to 29 inside PR #160, then to 36 and 40 by the end of PR #173. It runs as npm run test:hooks inside the verify chain the pre-push hook runs, so a regression in the hook fails a push. Forty passing cases as of this writing.
The rule I took from this: I accept false positives on a security gate. A commit message containing the words gh pr merge is blocked; the workaround is git commit -F file or gh pr create --body-file, and four test cases pin that behaviour. I would rather explain the workaround than widen the gate.
The exception that opened a hole
The hook blocked everything, and that was wrong too. Since 2026-07-07 blog-content PRs had a lighter, documented tier: self-merge on a green build and e2e run, no external review. The hook broke that pipeline, and the external reviewer’s quota was exhausted at the time, so routing content through the full gate would have stalled publishing.
PR #173 added one narrow exception. The command must be exactly gh pr merge <N> plus a short list of merge flags, anchored start to end:
grep -qE '^gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+[0-9]+([[:space:]]+--(squash|delete-branch|merge|rebase))*[[:space:]]*$'
Then the hook calls gh pr view <N> --json files, and every file must fall under a content allowlist with at least one real post under src/content/blog/.
The second review pass on that same PR found two gaps in the exception I had just written.
The allowlist admitted public/ as a bare prefix. That includes public/_headers, where the Content Security Policy lives. This matters because of PR #67 from 2026-07-05, whose commit message records the incident: “GA4 was blocked for ALL visitors since launch”. A blog PR that also touched _headers would have merged through the content exception. The allowlist is now public/(images|carousels)/, and a test case is named “blog post + public/_headers must still block”.
The flag list included --auto. That flag defers the merge until CI passes, but the file check runs once, at approval time. A push to the same branch in between would merge unchecked files. --auto is no longer allowed on the exception path.
The rule I took from this: an exception to a guardrail is new attack surface and gets the same review as the guardrail. One residual gap is documented rather than closed: the file check and the merge are two separate gh calls, so a push in the gap between them is not re-checked. The hook’s comment calls it “a theoretical race, not a practical one” for a single-operator repo. I agree, and I wrote it down.
The gate that could approve itself
The merge script, scripts/safe-merge-prs.sh, accepts an approval only when it is anchored to the current head commit. A thumbs-up older than the head does not count; a review on a different commit does not count. I covered why in how I pin a self-review to one commit.
On 2026-09-08 I added a fourth approval path, because the external reviewer’s quota is shared, and when it runs out the merge queue stalls with no red check to show for it. Two PRs had been sitting for three days behind exactly that. The new path is a comment by the runner carrying a marker:
self-review: <full 40-char head sha> verdict=pass
The script checks the author and the exact SHA:
'[.[] | select(.user.login == $me and (.body | test("self-review:[[:space:]]*" + $sha + "[[:space:]]+verdict=pass")))] | length'
Any push after the review changes the SHA and the marker stops matching. verdict=fixes-required records a review without approving.
The counterweight went in the same PR (#185). A self-approval path that can approve changes to the self-approval path is unsound. So scripts/safe-merge-prs.sh and .claude/hooks/block-direct-merge.sh joined the files whose PRs are held for me regardless of how they were reviewed, next to public/_headers, public/_redirects, astro.config.mjs and the CI workflows. The commit message names the awkward part: that PR was the one exception to the rule it introduced, merged on my direct instruction.
The same PR fixed a second silent blocker found while draining the queue. The CI check required every job to be SUCCESS. The Dependabot job skips on every PR Dependabot did not open, and SKIPPED is not SUCCESS, so a human PR could never pass. Skipped and neutral checks are now dropped, and a rollup of nothing but skipped jobs reads as empty rather than green.
The rule I took from this: when I add an approval path, I list the files that path must never approve. And a queue that does not drain has to say why in its output; both blockers were invisible until I added a debug print.
The one that was not about the agent
On 2026-09-24 the Astro 7 and MDX 8 upgrade landed as one PR (#198). Dependabot had opened two separate PRs, #192 and #189, and neither could install on its own: MDX 8 requires Astro 7 and MDX 4 requires Astro 5. The merge gate was working as designed and the pair was still stuck, because the gate checks approval and CI, not whether two green PRs are jointly installable.
No hook fixes this. The rule is in my head: when a dependency bot splits a peer-locked pair, close both and open one.
Commit trailers
One more hook, and the only one I cannot show you from this repo. Between 2025-11-27 and 2026-08-31, 82 commits here carry a Co-Authored-By: Claude ... trailer that the tool adds by default. None after that date. A local commit-msg hook now rejects the trailer, and the agent’s instructions tell it to leave the trailer out rather than hit the block and retry. The hook is not versioned in this repo, which is a gap: a local rule does not travel with the repo.
What I would do differently
Enforce first, document second. The eight months between the CLAUDE.md rule and the hook were eight months of relying on a model reading a sentence.
Write the bypass tests before the hook, not after review finds the bypasses. Every class in the test file (newline, IFS, substitution, eval, bash -c, malformed input, asymmetric boundary) is a thing shell does that a grep does not. That list was knowable up front.
Treat every exception as a PR against the gate, with the same review. Both real holes in this story came from the exception, not the rule.
For how the review loop runs day to day, see the code review workflow I run with Claude Code.
What I did not measure
How many times the hook has blocked a real merge attempt versus a false positive on a commit message. The hook emits a decision and no log, so there is no count.
Whether the agent ever tried an obfuscated merge on its own. The bypasses in the test file were found by code review and verified by hand, not observed in the wild.
The cost of the false positives. Every --body-file workaround is a few seconds, and I never added them up.
How long PRs sat in the queue before the self-review path existed, beyond the three days for the two PRs named in PR #185.
The size of the race window in the content exception. I called it theoretical and did not time it.