A coding agent runs real shell commands in your real repository. Most of the time that is fine. Occasionally it is rm -rf against the wrong directory, or a force-push to main. This guide shows two ways to stop that in Claude Code: a hook you write yourself, and the free agent-guardrails plugin.
What a PreToolUse hook is
Claude Code hooks are commands that run at set points in the agent loop. A PreToolUse hook runs before a tool call, such as a Bash command or a file edit. It receives the call as JSON on standard input. It can allow the call, or block it and send a reason back to the agent. The agent reads the reason and usually tries something safer.
The manual way
You need two pieces: a hook entry in .claude/settings.json, and a script. This entry runs the script before every Bash call.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block_rm.py\""
}
]
}
]
}
}
Save the script as .claude/hooks/block_rm.py. It reads the hook payload, looks at tool_input.command and exits with code 2 to block.
#!/usr/bin/env python3
import json, re, sys
data = json.load(sys.stdin)
command = (data.get("tool_input") or {}).get("command", "")
if re.search(r"\brm\s+-\w*[rR]\w*\s+(/|~|\.)(\s|$)", command):
sys.stderr.write("Blocked: rm -r on /, ~ or the working directory.\n")
sys.exit(2)
sys.exit(0)
Exit code 2 blocks the call, and whatever you write to standard error is shown to the agent. Exit code 0 allows it.
The other route is to exit 0 and print JSON on standard output. A permissionDecision of deny blocks the call in the same way.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked: rm -r on /, ~ or the working directory."
}
}
This works, and it is a good way to learn how hooks behave. The catch is upkeep. One regex covers one mistake. You also have to cover force-pushes, git reset --hard, DROP TABLE, piping curl into a shell, and so on. Then you need tests, a way to make exceptions, and a record of what was blocked.
The plugin way
agent-guardrails packages those checks as a Claude Code plugin. It uses only the Python 3 standard library, so you need python3 on your PATH and nothing else. Install it from inside Claude Code:
/plugin marketplace add RichFloForge/agent-guardrails
/plugin install agent-guardrails@agent-guardrails-marketplace
The defaults work with no config. If you want a file to edit, run this in your project:
/agent-guardrails:init
That writes .claude/guardrails.json, which is your active policy, plus .claude/guardrails.example.jsonc, a commented reference the plugin does not read. If the config file already exists, the command refuses to overwrite it unless you pass --force.
What destructive_shell blocks by default
The destructive_shell rule checks every Bash command. In the default config it blocks:
rm -rfaimed at/,~,$HOME,.,..or*. A narrower target such as./buildis not matched.- Force-pushing to a protected branch, or deleting one. A force-push with no explicit branch is also blocked, because the target cannot be known.
git reset --hardandgit clean -fd.git branch -Don a protected branch.DROP TABLE,DROP DATABASEandDROP SCHEMA.truncate,mkfs,dd if=andchmod -R 777.curlorwgetpiped intosh,bashand similar shells.- The classic fork bomb.
A normal push to main is not blocked. Only force-pushes and deletions are. The protected branches default to main, master and production.
When a command is blocked, the agent sees one line like this:
[agent-guardrails] destructive_shell: blocked - git reset --hard (discards uncommitted work). To allow: edit .claude/guardrails.json (add a regex to rules.destructive_shell.allow_patterns, or set rules.destructive_shell.mode to "warn").
Under the hood the plugin does what the manual way does: it exits with code 2, writes the reason to standard error, and prints a JSON deny decision.
Add your own patterns and branches
Your config is merged over the defaults. Objects merge key by key. Lists replace the default list, so you only write what you change, but a list you write must be complete. If you set protected_branches, repeat main, master and production if you still want them.
Branch entries can be exact names or globs such as release/*. Extra destructive patterns are regular expressions, matched anywhere in the command.
{
"protected_branches": ["main", "master", "production", "release/*"],
"rules": {
"destructive_shell": {
"extra_patterns": [
"\\bkubectl\\s+delete\\s+namespace\\b",
"\\bgit\\s+stash\\s+(drop|clear)\\b"
],
"allow_patterns": [
"^git clean -fd build/$"
]
}
}
}
With that file, kubectl delete namespace and git stash drop are blocked, and so is a force-push to any release/ branch. Remember that JSON needs each backslash doubled, as above.
Allow an exception
You have three options, from narrowest to broadest.
- An allow pattern. If a command matches any regex in
allow_patterns, it is exempt from the whole rule. In the example above,git clean -fd build/is allowed. Anchor the pattern with^and$. An unanchored pattern would also let through a longer command that merely contains the safe one. - Warn mode. Set
modetowarnand the command is allowed, a warning is printed and the decision is logged. - Turn the rule off. Set
enabledtofalse. Do this last.
{ "rules": { "destructive_shell": { "mode": "warn" } } }
By default the agent cannot edit .claude/guardrails.json, so it cannot quietly loosen its own policy. You make the change yourself.
One thing to avoid: the guardrails:allow marker is for secret detection in file contents. It does nothing for shell commands.
Honest limits
This is pattern matching. It is not a sandbox. It can be bypassed by obfuscation: building a command from variables, encoding it, running bash -c with a string assembled at run time, using symlinks, or calling a script that does the damage inside. Hooks only see tool calls Claude Code makes. They do not see what a script does internally or what other processes do.
Treat the plugin as one layer. Pair it with real sandboxing, least-privilege credentials, Claude Code's own permission settings, backups and code review.
The hook fails open by default. If the plugin receives unreadable input or hits an internal error, it allows the call and logs a warning, so a bug never stops your work. Set "fail_closed": true if you would rather block.
Next, read how to keep an audit log of what the agent did, or see three complete policies to copy from.