Environment-Aware Hooks in Claude Code: Build a Staging/Prod Safety Net That Doesn't Rely on Human Memory

If you've ever deployed something that worked perfectly in staging and then blew up in production, this post is for you. I'll walk you through how I wired Claude Code's hook system to detect the current environment and block destructive commands before they run — no discipline required, no checklists to forget.
1. Why This Matters Now
The standard approach to environment safety is "be careful." That works until it doesn't — usually at 11 PM on a Friday. The real problem isn't carelessness; it's that humans have no runtime enforcement layer between their intent and the command that actually runs.
Claude Code's hook system gives you exactly that layer. Instead of trusting a runbook or a mental checklist, you place a gating script between every tool call and its execution. The environment checks happen automatically, every time, before anything touches a real resource.
The pain this solves is specific: commands that are safe in staging (DELETE FROM sessions WHERE expires_at < NOW()) become catastrophic in prod if your WHERE clause is slightly wrong. The hook doesn't care how confident you feel — it reads DEPLOY_ENV and decides.
2. The Core Idea
Block at the gate, not at the scene. PreToolUse fires before execution; PostToolUse fires after. If you attach your environment logic to PostToolUse, you're writing an incident report, not preventing an incident.
Think of the hook as an airport security checkpoint. It doesn't matter how trustworthy the passenger is — everyone goes through the scanner. The scanner reads the environment variable (DEPLOY_ENV), checks the payload (the command string), and either waves you through or stops you cold.
| Hook Type | Fires | Use Case |
|---|---|---|
PreToolUse |
Before execution | Blocking dangerous commands |
PostToolUse |
After execution | Audit logging, Slack alerts |
Stop |
When Claude finishes | Summary notifications |
The key insight is that you can register multiple matchers. I initially only gated Bash calls, and then watched Claude write directly to a production config file using the Write tool. Each tool type needs its own gate.
3. How to Implement It
Start with the hook registration in .claude/settings.json. This tells Claude Code which script to call before any Bash or Write tool fires.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/env-gate.sh"
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/write-gate.sh"
}
]
}
]
}
}
Now create ~/.claude/hooks/env-gate.sh. The hook receives the full tool call as JSON on stdin — parse it, extract the command string, then apply your environment rules.
#!/usr/bin/env bash
# env-gate.sh — environment-aware command gate
ENV="${DEPLOY_ENV:-development}"
TOOL_INPUT=$(cat)
# Extract the command string from the JSON payload
CMD=""
if echo "$TOOL_INPUT" | grep -q '"command"'; then
CMD=$(echo "$TOOL_INPUT" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('command',''))")
fi
# Production: block destructive SQL and filesystem commands
if [ "$ENV" = "production" ]; then
if echo "$CMD" | grep -qiE '(DROP|TRUNCATE|DELETE FROM|rm -rf)'; then
echo '{"decision":"block","reason":"Destructive commands are blocked in production"}'
exit 0
fi
fi
# Staging: only allow calls to internal domains
if [ "$ENV" = "staging" ]; then
if echo "$CMD" | grep -qE 'curl|wget|fetch' && \
! echo "$CMD" | grep -q 'staging\.internal'; then
echo '{"decision":"block","reason":"Staging only allows internal domain calls"}'
exit 0
fi
fi
# Default: approve
echo '{"decision":"approve"}'
Make it executable:
chmod +x ~/.claude/hooks/env-gate.sh
Now create a parallel gate for Write operations. File writes bypass the shell entirely, so they need separate logic.
#!/usr/bin/env bash
# write-gate.sh — block writes to production config files
ENV="${DEPLOY_ENV:-development}"
TOOL_INPUT=$(cat)
FILE_PATH=$(echo "$TOOL_INPUT" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('path',''))" 2>/dev/null)
if [ "$ENV" = "production" ]; then
if echo "$FILE_PATH" | grep -qE '\.(env|config\.prod|production\.yaml)$'; then
echo '{"decision":"block","reason":"Direct writes to prod config files are blocked"}'
exit 0
fi
fi
echo '{"decision":"approve"}'
Verify the gate works before relying on it:
# Simulate a production block
DEPLOY_ENV=production echo '{"command":"DROP TABLE users"}' | bash ~/.claude/hooks/env-gate.sh
# Expected: {"decision":"block","reason":"Destructive commands are blocked in production"}
# Simulate a staging pass
DEPLOY_ENV=staging echo '{"command":"curl staging.internal/health"}' | bash ~/.claude/hooks/env-gate.sh
# Expected: {"decision":"approve"}
In my testing on an n8n 2.8.4 environment, the round-trip hook latency averaged 18ms — completely imperceptible in practice.
4. What to Watch in Production
The PostToolUse notification layer. Once blocking is working, add visibility. This script fires after every successful prod command and ships a Slack alert so you always know what ran — even if you weren't watching.
#!/usr/bin/env bash
# notify-prod.sh — PostToolUse Slack notification for production
ENV="${DEPLOY_ENV:-development}"
if [ "$ENV" = "production" ]; then
RESULT=$(cat)
TOOL=$(echo "$RESULT" | python3 -c \
"import sys,json; d=json.load(sys.stdin); print(d.get('tool_name','unknown'))")
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"[prod] Hook passed — tool: ${TOOL}\"}" > /dev/null
fi
Register it under PostToolUse in settings.json with matcher: "*" or a specific tool name.
The mistakes I made so you don't have to. I first attached the environment check to PostToolUse. That's the wrong hook — the command already ran. Moving to PreToolUse was the fix. I also only gated Bash initially, which meant Write calls walked straight through. Add a matcher for every tool type that can cause side effects.
Environment variable sourcing. On Docker or CI, DEPLOY_ENV may not be exported into the subprocess that runs your hook. Test with env | grep DEPLOY_ENV inside the hook script during development. If it's missing, your gate silently defaults to development and approves everything — that's the failure mode to catch early.
macOS vs. Linux grep. The -E flag behavior is consistent across both, but -P (Perl regex) is not available on macOS's default grep. The patterns above use -E only, which is safe everywhere.
What to tighten next. The regex blocklist in env-gate.sh is a starting point, not a complete policy. You'll want to extend it based on what your stack actually runs — psql -c, mongodrop, redis-cli FLUSHALL, and so on. Keep the blocklist in a separate file (e.g., ~/.claude/hooks/blocked-patterns.txt) and grep -f against it so you can update rules without touching the script logic.
The three-month result after applying this: zero rollbacks. Before, I was averaging two or three per month caused by commands that "should have been fine." The hook layer doesn't make you more disciplined — it makes discipline irrelevant.
Next logical step: add environment detection to your Stop hook as well, so Claude's session summary includes which environment it was operating in. That context is useful when reviewing logs two weeks later.
🐦 Faster updates on X: @baegseungh7061
📚 More in this series: Code Advanced
💌 Subscribe: Follow on X or grab the RSS
댓글
댓글 쓰기