Build a Claude Code Mod That Blocks Force Pushes, and Test It Offline
Claude Code mods run inside the tool itself, so a few lines can refuse a dangerous command before the shell sees it. Here is a force-push guard built from scratch, validated and tested offline, with an honest table of what it catches and what slips past.
show ip route | toc (6 sections)
Claude Code can now be changed from the inside. A mod is a small plugin whose functions run in the
same process as the tool, and I wanted to try the smallest useful one: a rule that refuses
git push --force before the shell ever sees it. The idea came from a
write-up by Chew Loong Nian
that builds a context meter and a force-push guard in about fifty lines. I wrote my own version from
scratch, ran it through the validator and the test runner on Claude Code 2.1.289, and noted what it
cannot catch, which turned out to be the most useful part.
What a mod is, and how it differs from a hook
Claude Code already had settings hooks: a shell command, web request, prompt or agent that fires when
something happens and talks back through JSON. A mod is loaded once and stays loaded. Because its
handlers are ordinary functions inside the process, they can keep variables between events, answer a
slash command instantly without a model call, and redraw pieces of the interface. The two things I
used are the ones that matter for safety: a handler on tool.call that can return a
deny with a reason, and a registered command so I can ask the mod what it blocks.
The layout is three small pieces. A manifest at .claude-plugin/plugin.json, a
hooks/hooks.json that points at the module, and the module itself, which exports a
register function. Inside it, on(event, matcher, hook) adds a hook, and each
hook receives the engine, the event, and a next function that passes the call along to
whatever sits beneath. Return without calling next and you have answered the event
yourself.
The mod, in four small files
The manifest names it and points nothing else:
{ "name": "push-guard", "version": "0.1.0", "description": "Refuses git force pushes before the Bash tool runs them", "author": { "name": "breakfixlearn" } }
The hooks file lists the one module to load:
{ "modules": ["./register.ts"] }
I kept the rule in its own file so it can be tested as a plain function. It splits the command line
on the usual separators, so a push chained after && or a semicolon is still seen,
then looks for git followed by push and any force spelling after it:
// Split a shell line into the commands it chains, then look for a force push in each.
const SEPARATORS = /&&|\|\||[;|\n]/
export function isForcePush(line: string): boolean {
return line.split(SEPARATORS).some(part => {
const words = part.trim().split(/\s+/)
const push = words.findIndex((w, i) => w === 'git' && words.slice(i + 1).includes('push'))
if (push === -1) return false
return words.slice(push).some(
w => w === '--force' || w === '-f' || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(w) || w.startsWith('+'),
)
})
}
The module registers a /push-guard command at session start, answers it with a fixed
sentence, and puts a hook in front of the Bash tool. A force push gets a toast and a refusal the model
can read; everything else goes through next untouched:
import type { Register } from 'claude-code'
import { isForcePush } from './rule'
export const register: Register = on => {
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'push-guard',
description: 'Explain what push-guard blocks',
})
return next(e)
})
on('command.run', { command: 'push-guard' }, async () => ({
text: 'push-guard refuses git push --force, -f and +refspec. --force-with-lease is allowed.',
}))
on('tool.call', { tool: 'Bash' }, ($, e, next) => {
if (!isForcePush(e.command)) return next(e)
$.ui.toast('push-guard blocked a force push')
return { deny: 'push-guard: force pushes are blocked here. Use --force-with-lease, or ask the user to run it.' }
})
}
I made one policy choice on purpose. --force-with-lease is allowed, because it refuses
to overwrite commits you have not seen, which makes it the safer habit rather than the dangerous one.
Change that if your team's rule is stricter.
Validate it, then test it with no sign-in
claude plugin validate reads the manifest and the module the way the engine will and
reports what the module hooks and what it calls. For this mod it listed the session start hook, the
command hook, a tool.call hook on Bash, and exactly two calls: the command registration
and the toast. That listing is the part I would look at before installing anyone else's mod, since
it is a quick answer to what the code can touch. Mine also asked me to add author details, which I did.
claude plugin test runs the folder's test files against the engine in an environment
with no network, file system or process access, so no account or model is involved. I wrote three
tests: the rule flags the usual spellings, the rule leaves ordinary pushes and lookalikes such as
git log -f alone, and the Bash hook returns a refusal that mentions the guard.
import { describe, expect, test } from 'claude-code/testing'
import { isForcePush } from './rule'
describe('isForcePush', () => {
test('flags the usual spellings', async () => {
for (const line of [
'git push --force',
'git push -f origin main',
'git push origin +main',
'git status && git push --force origin main',
'cd repo; git push -fu origin dev',
]) expect(isForcePush(line)).toBe(true)
})
test('leaves ordinary pushes and lookalikes alone', async () => {
for (const line of [
'git push origin main',
'git push --force-with-lease origin main',
'git log --format=%h -f',
'echo "push -f is risky"',
]) expect(isForcePush(line)).toBe(false)
})
})
describe('the Bash hook', () => {
test('denies a force push and says why', async $ => {
const ran = await $.tool.call({ tool: 'Bash', command: 'git push --force origin main' })
expect(ran.deny).toMatch(/force pushes are blocked/)
})
})
All three passed in about a third of a second. If you are newer to this kind of testing, it is the same habit I try to follow in my own labs, and it is why I only let coding assistants near real gear after testing: check the behavior somewhere harmless first.
What this guard misses
The rule reads the text of the command, so I tried it against a handful of inputs, including some meant to dodge it. Here is what I got:
| Command | Result |
|---|---|
| git push --force origin main | Blocked |
| git push origin main --force | Blocked |
| git -C repo push --force | Blocked |
| git push origin +main | Blocked (leading plus is a force) |
| git push --force-with-lease | Allowed on purpose |
| bash -c 'git push --force origin main' | Not caught, the quote hides the word git |
| g=--force; git push $g | Not caught, the flag lives in a variable |
That is the honest shape of a text-matching guard. It catches the accidental force push, the one a tired person or a hurried model types in the normal way, and it does nothing against a command that has been written to hide itself. If the real goal is that nobody can rewrite a shared branch, that control belongs on the server, as a protected branch that rejects force pushes whatever the client does. The mod is a seatbelt in front of the real barrier, and it is worth having for how cheap it is.
Why a security blog cares about this
An agent that can run shell commands has real authority over your repositories and machines. The OWASP Top 10 for LLM Applications (2025 edition) lists excessive agency as its own risk, and the usual advice is to limit what the agent can do and to put a check in front of the dangerous actions. A mod that denies a short list of commands is a small, readable way to do that, and it runs before the tool does rather than after.
The same property cuts the other way. A mod is code that runs with Claude Code's own access on your
machine, written by whoever published it. One getting-started guide I read tells people to read the
repo first and install only from authors they trust, which is the right instinct and the same one you
would apply to any package. Run claude plugin validate to see what a mod calls, read the
source, and prefer a rule you wrote yourself over a stranger's. Fifty lines you understand beat five
hundred you do not.
Frequently asked questions
What is a Claude Code mod?
A mod is a plugin whose JavaScript or TypeScript functions run inside Claude Code's own process, instead of as separate shell commands. It can step into a tool call and allow or refuse it, add a slash command that answers without a model call, and change parts of the interface. Mods arrived in Claude Code 2.1.287, according to the write-up that inspired this post, and are on by default.
How is a mod different from a settings hook?
A settings hook starts a shell command, HTTP request, prompt or agent when an event fires and passes data back and forth as JSON. A mod is loaded once and stays loaded for the session, so its handlers can share variables, keep state and draw on screen. That in-process access is also why a mod deserves more suspicion than a one-line hook.
How do I check what a mod does before installing it?
Run claude plugin validate on the mod folder. It reads the manifest and the module source and prints which events the module hooks and which Claude Code APIs it calls, for example tool.call on the Bash tool and the command and toast calls. Reading the source is still the real check, because the listing shows what is called, not why.
Can I test a mod without signing in or calling a model?
Yes. claude plugin test runs every test file in the folder against the engine itself, in an environment with no network, file system or process access. My three tests for this guard ran in about a third of a second and needed no account.
Does the guard stop every force push?
No. It matches the text of the command, so it catches git push with --force, -f or a leading plus sign, including after && or a semicolon, but it misses a push wrapped in bash -c or built from a shell variable. Treat it as a guard against accidents. The control that actually holds is a protected branch on the server that rejects force pushes.
Why does a post about AI coding tools belong in a security blog?
Because an agent that can run shell commands has real authority over your repositories and machines. The OWASP Top 10 for LLM Applications (2025 edition) lists excessive agency as its own risk, and a small deny rule in front of the riskiest commands is a cheap way to reduce it. Layer it with permissions and server-side protections instead of relying on any one of them.