Two small hooks for Claude Code. They run before every Bash command that the agent wants to execute, and they block the command when it is risky.
protect_master.pyblocks git operations that changemasterormain.limit-test-workers.pyblocks test runners that would start too many parallel workers for the memory that the machine has free right now.
Both hooks are plain Python 3 scripts with no dependencies.
The hook blocks a Bash command when it would do one of these things:
git merge,git rebaseorgit reset(when the reset moves the branch) while the current branch ismasterormain.git pushtomasterormain. This includes an explicit refspec likeHEAD:mainor+HEAD:refs/heads/master, a push with no refspec while the current branch ismasterormain, andgit push origin --delete main.git push --all,--mirroror--branches, because they includemasterandmain.
When it blocks, it exits with code 2 and prints the reason to stderr. Claude Code shows that reason to the agent.
An agent can merge, push or reset a production branch in a few seconds, and
that is hard to undo. These operations should happen only when the human
explicitly asks for them. The hook does not read the conversation, so it cannot
know whether the human authorized the operation. Because of that, it always
blocks, and the message asks the agent to let the human run the command
themselves. In Claude Code, the human can do that by typing the command with a
! prefix in the prompt. Commands run that way do not go through the hooks.
A simple text search for "master" or "main" blocks too much. For example, it
would block a push of a feature branch when the same command also runs
git fetch origin master, or when a PR body contains the word "main". This
hook works in a more precise way:
- It removes heredoc bodies and splits the command into segments on
&&,||,;,|,&and newlines, outside quotes. - It follows
cd,pushd,git -C <dir>,git checkoutandgit switchin order, so it knows which directory and which branch each git command uses. - It expands variables that are set in the same command, like
W=/path; git -C $W push. - It asks git for the current branch of each directory
(
git rev-parse --abbrev-ref HEAD). - It skips common prefixes such as
env,VAR=valueand shell keywords such asdoandthen.
If the parser fails on a command, the hook falls back to a simple rule: it
blocks the command if it contains push, merge, reset or rebase and
also master or main.
The hook blocks a test command when it does not limit its concurrency, or when it asks for more workers than the current limit. It knows these runners:
| Runner | Flag it expects |
|---|---|
| jest | --maxWorkers=N (or --runInBand) |
| vitest | --maxWorkers=N |
node --test, tsx --test |
--test-concurrency=N |
| playwright test | --workers=N |
parallel_tests (parallel_rspec, parallel_test) |
-n N |
| turbo test | --concurrency=N |
| nx run-many / affected test | --parallel=N |
| lerna run test | --concurrency N |
rails test with parallelize |
PARALLEL_WORKERS=N |
bun test --parallel |
--parallel=N |
| pnpm recursive test | --workspace-concurrency=N |
It also follows npm test, npm run <script>, pnpm <script>, yarn and
bun into package.json, including scripts that call other scripts, and it
looks inside bash -c "...". It only looks at the runner in command position,
so npm i -D jest or grep jest are not blocked.
When it blocks, it returns a JSON deny decision. The reason tells the agent
the exact value of N to use, so the agent can run the command again with the
right flag.
Many test runners start one worker per CPU core by default. Each worker can use
around 1.5 GB of RAM. On a 16 GB machine, one npx jest with no limit can use
all the memory, and when several agents run tests at the same time, the
machine can freeze. The hook keeps the total number of test workers under
control without changing any project configuration, which other developers
and CI also use.
- It reads the free memory level from macOS (
sysctl kern.memorystatus_level): 60% or more gives 4 workers, 40% or more gives 3, 20% or more gives 2, and less gives 1. - It reads the used swap (
sysctl vm.swapusage). With 2 GB or more of swap the maximum is 2, and with 4 GB or more it is 1. - It counts large test processes that are already running (node, tsx, ruby or bun processes over 200 MB that look like test runners) and subtracts them from a total budget of 6 workers.
- The result is always between 1 and 4.
You can change the total budget with the CLAUDE_TEST_WORKER_BUDGET
environment variable.
-
Copy the hooks to your Claude Code hooks folder:
mkdir -p ~/.claude/hooks cp hooks/protect_master.py hooks/limit-test-workers.py ~/.claude/hooks/
-
Add the hook entries from
examples/settings.jsonto thehookssection of~/.claude/settings.json. If you already have aPreToolUselist, add the two entries to it instead of replacing it. -
Start a new Claude Code session. The hooks apply to every Bash command.
limit-test-workers.py uses /usr/bin/python3 on purpose, because a Python
version manager shim (like pyenv) can add almost half a second to every Bash
command. If your system Python is somewhere else, change the path in
settings.json.
The tests do not need any package. Run them from the repository root:
python3 tests/test_protect_master.py
python3 tests/test_limit_test_workers.pytest_protect_master.py uses the PROTECT_MASTER_BRANCHES environment
variable to tell the hook which branch each fake directory is on, so it does
not need real repositories. test_limit_test_workers.py sets the worker limit
to 2, so the result does not depend on the memory of your machine.
protect_master.pyonly protects branches namedmasterandmain.- It does not see git aliases, scripts that call git inside them, or
operations through other tools such as
gh pr mergeor the GitHub API. - With
push.default=upstream, a plaingit pushfrom a local branch with another name that tracksmainis not blocked. - The fallback rule for commands that the parser cannot read is simple, and
it only blocks when the command names
masterormain. protect_master.pycannot detect that the human authorized an operation. It always blocks, and the human has to run the command or disable the hook.limit-test-workers.pyreads memory and swap with macOSsysctlkeys. On other systems these reads fail and the limit falls back to 2 workers.- If
limit-test-workers.pyhits an unexpected error, it lets the command run and writes the error to~/.claude/hooks/limit-test-workers.log. It never blocks a command because of its own bug. - The two hooks block in different ways:
protect_master.pyuses exit code 2 and stderr, andlimit-test-workers.pyuses a JSONdenydecision. - Both hooks parse shell commands with their own simple parser, not a full shell. Unusual quoting or very dynamic commands can get past them.
MIT. See LICENSE.