
Pre-Commit Hook Validation Chains: How to Build a Multi-Stage Gate Pipeline in Claude Code
If you've ever shipped a lint error to production or accidentally committed an API key, a gate pipeline isn't optional — it's the fix.
Quick answer
- Claude Code's
hooksarray in.claude/settings.jsonruns sequentially — the first non-zero exit code stops the chain completely. - Order your gates fastest-first: lint (~0.8 s) → secret scan → type check. Running
tscbeforeeslintwastes time every time lint would have caught something earlier. - Always wrap external calls with
timeout 10 <command>inside hook scripts. Without it, a slow network response hangs the entire chain at 60 seconds — the default timeout — with no useful error.
1. Why this matters now
The local development loop has a reliability problem. CI catches issues — but CI runs after the push. By the time a failing type check surfaces, a PR is already open, a teammate has been notified, and the queue is blocked. The feedback cycle that should be seconds becomes minutes or hours.
Claude Code's Hooks system was designed to close that gap. By running validation at tool-use boundaries rather than at push time, you can enforce the same rules that CI enforces — locally, silently, and before the commit even forms.
The real pain isn't the one lint error you catch on the first go. It's the three you don't catch because CI was flaky, the branch was stale, or someone force-merged under pressure. A local gate pipeline is the only check that runs every single time, regardless of what's happening upstream.
2. The core idea
A hook pipeline is a sequential series of guards: if any one of them returns a non-zero exit code, the entire chain halts and the triggering action is blocked.
Think of it like airport security. You pass through the X-ray, then the metal detector, then identity verification — in that order. Fail any single checkpoint and you don't reach the gate. There's no parallel processing, no skipping ahead. Claude Code's PreToolUse hook chain works exactly the same way.
The configuration lives in .claude/settings.json. Under the hooks key, you declare which tool triggers the chain ("matcher": "Bash") and then list each command in the order you want them enforced. Claude Code executes them top-to-bottom, stopping at the first failure.
| Gate | Tool | Typical runtime | Fail condition |
|---|---|---|---|
| Lint | eslint --max-warnings 0 |
~0.8 s | Any warning or error |
| Secret scan | gitleaks detect --no-banner |
~1.2 s | Any matched pattern |
| Type check | tsc --noEmit |
~4–8 s | Any type error |
The ordering is not arbitrary. Put the cheapest gate first. If lint fails, there's zero reason to run a full TypeScript compile. Reversing that order burns 4–8 seconds on every lint failure — and lint failures are frequent.
3. How to implement it
Start with the settings file. Add the hooks block to your project's .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/lint-check.sh" },
{ "type": "command", "command": "bash .claude/hooks/secret-scan.sh" },
{ "type": "command", "command": "bash .claude/hooks/type-check.sh" }
]
}
]
}
}
Now create the three scripts. Gate 1 — lint:
#!/bin/bash
# .claude/hooks/lint-check.sh
npx eslint . --max-warnings 0
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo '[GATE 1 BLOCKED] Fix lint errors before retrying.'
exit 1
fi
exit 0
Gate 2 — secret scan:
#!/bin/bash
# .claude/hooks/secret-scan.sh
timeout 10 gitleaks detect --no-banner
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo '[GATE 2 BLOCKED] Possible secret detected. Check gitleaks output.'
exit 1
fi
exit 0
Gate 3 — type check:
#!/bin/bash
# .claude/hooks/type-check.sh
npx tsc --noEmit
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo '[GATE 3 BLOCKED] TypeScript errors found. Run tsc --noEmit to review.'
exit 1
fi
exit 0
Make all three executable:
chmod +x .claude/hooks/lint-check.sh
chmod +x .claude/hooks/secret-scan.sh
chmod +x .claude/hooks/type-check.sh
Verify the chain works with a deliberate failure — introduce a lint warning, run a Bash tool from inside Claude Code, and confirm the hook blocks it. Then fix the warning and confirm it passes. Both states need testing.
# Check the exit code of the last hook run manually
bash .claude/hooks/lint-check.sh; echo "exit: $?"
Expected output on a clean repo: exit: 0. Any other number means the gate is active and working.
4. What to watch in production
The timeout trap. Claude Code's default hook timeout is 60 seconds. If you embed any network call — a remote linting service, an API-based secret scanner, a package registry check — and it responds slowly, the entire hook chain hangs until that timeout fires. The fix is always the same: wrap external commands with timeout N and split network-dependent checks into PostToolUse hooks where they can fail without blocking the primary action.
Fail-open behavior. If a hook script has a syntax error or fails to parse, Claude Code's default behavior is to pass through rather than block. This is the broken seatbelt problem — it looks like safety is in place but nothing is actually being checked. Always verify your hooks are running by checking echo $? after each command. A hook that doesn't output an exit code explicitly is a hook you can't trust.
False positives in secret scanning. gitleaks is aggressive by design. Test API tokens, fixture files with placeholder credentials, and documentation examples will all trigger it. Create a .gitleaks.toml allowlist for known-safe patterns before deploying this to a shared team repository — otherwise every other commit will hit a false block.
Environment differences. eslint and tsc behavior can differ between Node versions or when node_modules is stale. Pin your Node version in .nvmrc or use a Docker-based hook runner if you're running this across machines. A hook that passes locally but blocks in a teammate's environment is worse than no hook at all.
What to change next. Once the three-gate chain is stable, consider adding a fourth gate for test coverage thresholds using jest --coverage --coverageThreshold. Put it last — it's the heaviest. Also consider adding a PostToolUse hook that fires after a successful commit to log metadata (timestamp, branch, which gates passed) to a local audit file. That audit trail is surprisingly useful when debugging why a specific commit sailed through.
Sources and checks
| What to verify | How to check it |
|---|---|
| Hook execution order is top-to-bottom | Add echo "gate N running" to each script and trigger a Bash tool; confirm order in output |
| Default timeout is 60 s | Claude Code docs → Hooks → timeout behavior; or test with sleep 65 in a hook |
| gitleaks installation | gitleaks version; install via brew install gitleaks or the GitHub releases page |
| tsc noEmit behavior | Run npx tsc --noEmit in the project root; verify it exits 0 before wiring into the hook |
| Fail-open default | Intentionally break a hook script's syntax; confirm Claude Code passes through rather than blocking |
FAQ
When should I use Hooks?
Use a hook pipeline on any project where CI failures are costing time or where secret leaks are a real risk — which is most projects. The threshold is low: if you've ever seen a lint error reach a PR, or if anyone on the team has ever asked "did a key get committed?", a three-gate chain pays for itself in the first week.
What should I check before applying Hooks in production?
Before wiring hooks into a shared team repo, run each script manually and confirm it exits cleanly on a known-good codebase. Check whether gitleaks false-positives any existing files. Confirm that npx tsc --noEmit completes in a reasonable time — if your project takes 30+ seconds to type-check, you'll want to profile it before making it a synchronous gate.
What is the easiest way to verify the result?
The two-state test: introduce a deliberate failure (add an unused variable, paste a fake API key string), trigger the hook via a Claude Code Bash tool, and confirm it blocks. Then revert, trigger again, and confirm it passes. Log echo $? at the end of each script so the exit code is always visible. If you can't see the exit code, you can't verify the gate.
Closing
Order your gates by cost, wrap every external call with a timeout, and always verify exit codes explicitly — those three rules are what separate a working pipeline from a false sense of safety.
Next step: once the three-gate chain runs cleanly for a week, add a PostToolUse audit hook to log gate results. That data will show you which gate fires most often and where to invest in faster tooling.
🐦 Faster updates on X: @baegseungh7061
📚 More in this series: Code Advanced
💌 Subscribe: Follow on X or grab the RSS
댓글
댓글 쓰기