Three small PreToolUse guardrail hooks, extracted from a setup that runs Claude Code unattended (cron jobs, headless agents, multi-machine automation) every day. Each one exists because of a real incident — or a near miss.
| Hook | What it stops | Decision |
|---|---|---|
check-bash-safety.mjs |
Dangerous shell commands (rm -rf, sudo rm, git push --force, git reset --hard, DROP/TRUNCATE in SQL, destructive MCP calls like apply_migration, delete_branch, deploy_to_vercel, bulk email sends) |
ask — pops a confirmation with a plain-language explanation of the worst case |
secret-guard.py |
Real secrets (API keys, JWTs, private key blocks) being hardcoded into files that will be committed to git | deny — tells Claude to use an env-var reference instead; .env* files pass through |
cmd-ascii-gate.py |
Non-ASCII bytes written into .cmd/.bat files — cmd.exe parses batch files in the ANSI codepage, so a CJK comment or a smart quote can literally get executed as a command fragment |
deny |
An agent that runs 200 tool calls a day will eventually type the one command you'd never approve. Rules in a CLAUDE.md are suggestions; a PreToolUse hook is a mechanical gate that fires every time. The philosophy here:
ask, notdeny, for commands — dangerous ≠ wrong. The gate makes you look at it, with a one-line explanation of what the blast radius is, then you decide.denyfor secrets and encoding hazards — there is no legitimate reason to hardcode a live API key into a tracked file, so those don't get a dialog.- Fail-open — if a hook crashes or the payload shape is unexpected, the operation is allowed. These are safety nets, not an availability risk. A broken hook should never brick your session.
Hooks are registered in Claude Code's settings.json (official docs). Two placements, pick per hook:
Global (~/.claude/settings.json) — applies to every project. Best for check-bash-safety.mjs:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|PowerShell|mcp__.*__(execute_sql|apply_migration|delete_branch|pause_project|deploy_to_vercel|send-email|send-batch-emails|send-broadcast|send-event)",
"hooks": [
{ "type": "command", "command": "node /absolute/path/to/hooks/check-bash-safety.mjs" }
]
}
]
}
}Per-project (<project>/.claude/settings.json) — relative paths resolve from the project root. Best for the two Python gates:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "python .claude/hooks/secret-guard.py" },
{ "type": "command", "command": "python .claude/hooks/cmd-ascii-gate.py" }
]
}
]
}
}Both gates share the Write|Edit matcher and disambiguate internally (secret-guard skips .env* files; cmd-ascii-gate only acts on .cmd/.bat), so chaining them is safe.
Requirements: Node ≥ 18 for the .mjs hook, Python ≥ 3.9 for the .py hooks. No dependencies. Windows-friendly (stdin/stdout forced to UTF-8 to survive GBK consoles).
For check-bash-safety.mjs, the MCP tool names appear twice: in the settings.json matcher (decides whether the hook fires at all) and in the MCP_DANGER_TOOLS list inside the script (decides ask vs allow). If you add a new dangerous MCP tool, update both.
Every PreToolUse hook receives the pending tool call as JSON on stdin and answers with a permissionDecision (allow / ask / deny) plus a human-readable reason on stdout. That's the whole contract — each script here is a single file with a pattern list and a verdict, readable in five minutes. Fork them, edit the pattern lists, make them yours.
- claude-code-session-sharing — hand off in-progress sessions across machines
- claude-code-windows-hook — working Node.js hook template for Claude Code on Windows
- claude-code-windows-screenshot-paste — fix "No image found in clipboard" on Windows
MIT