claude-code-safety-hooks
v0.1.0
Published
Generic, config-driven safety hooks for Claude Code — free stuck dev ports, block force-push, enforce Conventional Commits, require tests, and gate deploys, all wired via Claude Code's PreToolUse/Stop hook model.
Maintainers
Readme
claude-code-safety-hooks
Guardrails for Claude Code that stop the most common self-inflicted footguns — before they happen.
A small, dependency-free set of config-driven hook scripts you drop into any repo. They plug into Claude Code's PreToolUse and Stop events and quietly enforce the boring-but-important stuff: don't force-push over a teammate, write a sane commit message, don't ship to prod by accident, don't leave code untested, and don't let a stuck dev server block the next run.
npx claude-code-safety-hooks init[demo GIF placeholder — record with asciinema / vhs:
npx claude-code-safety-hooks init→ paste the snippet → watch a force-push get blocked. Drop the.gifhere.]
What is this?
When you let Claude Code run Bash autonomously, it's fast — and occasionally too fast. It can git push --force onto a shared branch, fire off a deploy command, commit with a message that breaks your changelog tooling, or spin up a dev server on a port that's already taken. None of these are Claude being "wrong" exactly; they're the kind of slip a tired human makes too. The fix is the same one teams already use: cheap, deterministic guardrails.
Claude Code lets you register shell scripts that run before a tool call (and when the agent stops). A hook that exits non-zero can deny the action and hand its stderr back to the model so it corrects course. This package is a curated set of those hooks, extracted from a production monorepo and fully genericized — every project-specific value lives in a config file or an env var, nothing is hardcoded.
- ✅ Zero runtime dependencies. Pure
bash/sh+python3(for JSON parsing — present on macOS and virtually every dev box), with graceful fallbacks. - ✅ Config-driven. Ports, commit types, deploy patterns, test globs — all in one JSON file.
- ✅ Fail-open. A malformed payload never wedges your session; hooks only block when they're sure.
- ✅ Non-destructive install.
initprints the settings snippet; it never edits yoursettings.json.
The hooks
| Hook | Event | What it does |
|------|-------|--------------|
| guard-dev-ports.sh | PreToolUse:Bash | Frees your configured dev-server ports before a dev command runs, so EADDRINUSE never blocks startup. |
| block-force-push.sh | PreToolUse:Bash | Denies git push --force / --force-with-lease. Branch deletes are still allowed. |
| lint-commit-msg.sh | PreToolUse:Bash | Enforces Conventional Commits (type(scope): summary) on every git commit. |
| require-tests.sh | Stop | Warns (or blocks) when staged source files have no matching test file. |
| confirm-destructive-deploy.sh | PreToolUse:Bash | Blocks a configurable "deploy/release" command unless an explicit confirmation env var is set. |
Quick start
From the root of the repo where you use Claude Code:
npx claude-code-safety-hooks initThis will:
- Copy the hook scripts into
./.claude/hooks/(andchmod +xthem). - Write a default
claude-code-safety-hooks.config.jsonat your repo root (if you don't already have one). - Print the exact
.claude/settings.jsonsnippet to wire the hooks up. It does not modify yoursettings.jsonfor you — you merge it, so nothing you've already configured gets clobbered.
Then paste the printed hooks block into .claude/settings.json (see next section), tweak the config to taste, and restart Claude Code. Done.
npx claude-code-safety-hooks --help lists everything.
Wiring it up (.claude/settings.json)
init prints this exact snippet. If you already have a .claude/settings.json, merge the hooks block into it — append to any existing PreToolUse / Stop arrays rather than replacing them.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": ".claude/hooks/guard-dev-ports.sh" }]
},
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": ".claude/hooks/block-force-push.sh" }]
},
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": ".claude/hooks/lint-commit-msg.sh" }]
},
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": ".claude/hooks/confirm-destructive-deploy.sh" }]
}
],
"Stop": [
{
"hooks": [{ "type": "command", "command": ".claude/hooks/require-tests.sh" }]
}
]
}
}Global vs. project. The snippet above wires the hooks per-project (
.claude/settings.json). To apply them to every repo, put the samehooksblock in your global~/.claude/settings.jsonand copy.claude/hooks/(plus a config) into each repo — the hooks resolve config relative to the repo they run in.
Configuration
All behavior is driven by claude-code-safety-hooks.config.json at your repo root (the default written by init is shown below). Every value can also be overridden by an environment variable — handy for CI or one-off escape hatches. The env name is the config key, uppercased with dots → underscores, prefixed CCSH_ (e.g. commit.maxLength → CCSH_COMMIT_MAXLENGTH).
{
"devPorts": {
// Ports freed before a dev command starts.
"ports": [3000, 3001, 5173, 8080, 8081],
// Substrings that identify a "dev server" command.
"triggers": ["npm run dev", "pnpm dev", "pnpm run dev", "yarn dev",
"next dev", "vite", "expo start"]
},
"forcePush": {
"enabled": true // master switch for block-force-push.sh
},
"commit": {
"types": ["feat", "fix", "refactor", "perf", "style", "docs",
"test", "chore", "revert", "build", "ci"],
"scopes": [], // [] = accept any scope token; list them to restrict
"requireScope": false, // true = a scope is mandatory: type(scope): ...
"maxLength": 72, // subject-line length cap
"lowercaseSubject": true, // summary must start lowercase
"noTrailingPeriod": true // summary must not end with "."
},
"tests": {
"mode": "warn", // "warn" (advisory) | "block" (deny on Stop)
"sourceGlobs": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js", "src/**/*.jsx",
"lib/**/*.ts", "lib/**/*.js"],
"testPatterns": ["{dir}/{name}.test.{ext}",
"{dir}/{name}.spec.{ext}",
"{dir}/__tests__/{name}.test.{ext}"],
"exclude": ["**/*.d.ts", "**/index.ts", "**/index.js", "**/*.config.*",
"**/*.test.*", "**/*.spec.*", "**/types.ts", "**/constants.ts"]
},
"deploy": {
"commandPattern": "(deploy|release)\\b", // extended-regex marking a deploy
"confirmEnv": "CCSH_ALLOW_DEPLOY", // env var that unlocks it
"message": "…" // optional custom block message
}
}Config discovery order (first hit wins): $CCSH_CONFIG → ./claude-code-safety-hooks.config.json → ./.claude/claude-code-safety-hooks.config.json → the same two at the git root → the copy shipped next to the hooks. So the defaults work even before you write your own.
Per-hook detail
1. guard-dev-ports.sh
Problem: you ask Claude to restart the dev server, the old one is still bound, and you get Error: listen EADDRINUSE :::3000.
What it does: when a Bash command matches one of devPorts.triggers, it frees every port in devPorts.ports (via lsof, falling back to fuser) so the server starts clean. It is advisory — always exits 0, never blocks the command; it only ever touches ports you've told it to manage.
Customize:
"devPorts": { "ports": [3000, 4000], "triggers": ["npm start", "make serve"] }CCSH_DEVPORTS_PORTS="3000,4000" # comma/space separated
CCSH_DISABLE_GUARD_DEV_PORTS=1 # turn it off2. block-force-push.sh
Problem: git push --force onto a shared branch silently discards commits other people already pushed.
What it does: denies any git push carrying -f, --force, --force-with-lease, or --force-if-includes (exit 2, with an explanation returned to the model). Deleting a remote branch (git push origin --delete X or git push origin :X) is not a history rewrite and is allowed through.
Customize:
CCSH_FORCEPUSH_ENABLED=false # disable entirely
CCSH_ALLOW_FORCE_PUSH=1 # one-off escape hatch for a single push3. lint-commit-msg.sh
Problem: inconsistent commit subjects break changelog generation and make history hard to scan.
What it does: parses the commit message out of the git commit command (both -m "…" and <<'EOF' … EOF heredoc forms) and validates it against type(scope): summary — configurable type list, optional-or-required scope, lowercase summary, no trailing period, length cap. A breaking-change ! (feat!: …) is accepted. Commits with no visible message (plain git commit opening an editor, -F file, --amend --no-edit) are left alone.
Customize:
"commit": { "types": ["feat","fix","docs"], "scopes": ["api","web"],
"requireScope": true, "maxLength": 50 }CCSH_COMMIT_MAXLENGTH=50
CCSH_DISABLE_LINT_COMMIT_MSG=14. require-tests.sh
Problem: "every change ships with a test" is a great norm that's easy to forget mid-session.
What it does: on Stop, inspects your staged source files (things you deliberately git added). For each file matching tests.sourceGlobs (and not in tests.exclude), it checks whether a matching test exists per tests.testPatterns. In warn mode it prints the gaps and exits 0; in block mode it exits 2 and asks the model to add tests. Deletions are exempt (removing a module with its test is valid).
Customize:
"tests": { "mode": "block",
"sourceGlobs": ["packages/**/src/**/*.ts"],
"testPatterns": ["{dir}/{name}.test.{ext}", "{dir}/__tests__/{name}.test.{ext}"],
"exclude": ["**/*.d.ts", "**/index.ts", "**/infra/**"] }CCSH_TESTS_MODE=block
CCSH_DISABLE_REQUIRE_TESTS=1Tip: the
excludelist is where hard-to-unit-test infra goes — SDK wrappers, generated clients, barrelindexfiles, config. Add globs there rather than sprinkling skip comments through your code.
5. confirm-destructive-deploy.sh
Problem: a deploy/release command run by accident ships straight to production.
What it does: blocks any command matching deploy.commandPattern unless the deploy.confirmEnv variable is set (either exported into the environment, or typed inline as CCSH_ALLOW_DEPLOY=1 <cmd>). This encodes the staging-before-prod discipline generically: shipping becomes a deliberate, auditable step.
Customize:
"deploy": { "commandPattern": "kubectl apply|helm upgrade|serverless deploy",
"confirmEnv": "I_HAVE_TESTED_ON_STAGING" }CCSH_ALLOW_DEPLOY=1 npm run deploy # the sanctioned way to actually deploy
CCSH_DEPLOY_COMMANDPATTERN='gcloud .*deploy'
CCSH_DISABLE_CONFIRM_DEPLOY=1Why hooks?
Claude Code fires hooks at defined points in the agent loop. Two matter here:
PreToolUseruns before a tool executes. The hook receives the tool call as JSON on stdin — e.g.{"tool_name":"Bash","tool_input":{"command":"git push --force"}}. Its exit code is the verdict:0→ allow (stdout is shown to you, ignored by the model)2→ deny; the hook's stderr is fed back to the model, so it sees why and can adjust- any other code → non-blocking error (shown to you)
Stopruns when the agent finishes its turn — the natural place to check "did we leave the tree in a good state?" (e.g. staged code without tests). AStophook returning2nudges the model to keep working.
Because the model reads a blocked hook's stderr, these guardrails are teaching as much as blocking: a denied force-push tells Claude to use a normal push; a rejected commit message shows the exact format it should use. Deterministic, fast (milliseconds), and impossible to "forget" — which is exactly what you want protecting the irreversible actions.
Every hook here fails open: if it can't parse the payload, it exits 0 and gets out of the way. It only ever blocks when it's confident.
Compatibility
- Node ≥ 18 for the installer (
bin/cli.js). The hooks themselves arebash/sh. python3is used for robust JSON parsing (default on macOS; standard on Linux dev images). If it's absent, the hooks fall back to ased-based extractor for the common cases and otherwise fail open.lsof(orfuser) is used byguard-dev-ports.sh; if neither is present it no-ops.- Tested on macOS (
bash3.2 /zsh) and Linux.
Development
git clone https://github.com/oratis/claude-code-safety-hooks
cd claude-code-safety-hooks
npm test # runs test/run.sh — execs every hook with sample payloadsThe test harness asserts exit codes for each hook (force-push denied, good commit passes, bad one fails, deploy gated, tests required, …) and exits non-zero on any mismatch.
Contributing
Issues and PRs welcome. Good first additions: more testPatterns presets, extra dev-server triggers, or new opt-in hooks that follow the same fail-open, config-driven shape.
License
MIT © 2026 oratis
