A Claude Code plugin that acts as a safety net, catching destructive git and filesystem commands before they execute.
- Why This Exists
- Why Hooks Instead of settings.json?
- Prerequisites
- Quick Start
- Commands Blocked
- Commands Allowed
- What Happens When Blocked
- Testing the Hook
- Development
- Custom Rules (Experimental)
- Advanced Features
- License
We learned the hard way that instructions aren't enough to keep AI agents in check.
After Claude Code silently wiped out hours of progress with a single rm -rf ~/ or git checkout --, it became evident that "soft" rules in an CLAUDE.md or AGENTS.md file cannot replace hard technical constraints.
The current approach is to use a dedicated hook to programmatically prevent agents from running destructive commands.
Claude Code's .claude/settings.json supports deny rules for Bash commands, but these use simple prefix matching—not pattern matching or semantic analysis. This makes them insufficient for nuanced safety rules:
| Limitation | Example |
|---|---|
| Can't distinguish safe vs. dangerous variants | Bash(git checkout) blocks both git checkout -b new-branch (safe) and git checkout -- file (dangerous) |
| Can't parse flags semantically | Bash(rm -rf) blocks rm -rf /tmp/cache (safe) but allows rm -r -f / (dangerous, different flag order) |
| Can't detect shell wrappers | sh -c "rm -rf /" bypasses a Bash(rm) deny rule entirely |
| Can't analyze interpreter one-liners | python -c 'os.system("rm -rf /")' executes without matching any rm rule |
This hook provides semantic command analysis: it parses arguments, understands flag combinations, recursively analyzes shell wrappers, and distinguishes safe operations (temp directories, within cwd) from dangerous ones.
- Node.js: Version 18 or higher is required to run this plugin
/plugin marketplace add kenryu42/cc-marketplace
/plugin install safety-net@cc-marketplaceNote
After installing the plugin, you need to restart your Claude Code for it to take effect.
- Run
/plugin→ SelectMarketplaces→ Choosecc-marketplace→ Enable auto-update
Option A: Let an LLM do it
Paste this into any LLM agent (Claude Code, OpenCode, Cursor, etc.):
Install the cc-safety-net plugin in `~/.config/opencode/opencode.json` (or `.jsonc`) according to the schema at: https://opencode.ai/config.json
Then copy the following files to `~/.config/opencode/command/`:
- https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/.opencode/command/set-custom-rules.md
- https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/.opencode/command/verify-custom-rules.md
Option B: Manual setup
- Add the plugin to your config
~/.config/opencode/opencode.json(or.jsonc):
{
"plugin": ["cc-safety-net"]
}- Copy the commands to
~/.config/opencode/command/
| Command Pattern | Why It's Dangerous |
|---|---|
| git checkout -- files | Discards uncommitted changes permanently |
| git checkout <ref> -- <path> | Overwrites working tree with ref version |
| git restore files | Discards uncommitted changes |
| git restore --worktree | Explicitly discards working tree changes |
| git reset --hard | Destroys all uncommitted changes |
| git reset --merge | Can lose uncommitted changes |
| git clean -f | Removes untracked files permanently |
| git push --force / -f | Destroys remote history |
| git branch -D | Force-deletes branch without merge check |
| git stash drop | Permanently deletes stashed changes |
| git stash clear | Deletes ALL stashed changes |
| git worktree remove --force | Force-deletes worktree without checking for changes |
| rm -rf (paths outside cwd) | Recursive file deletion outside the current directory |
| rm -rf / or ~ or $HOME | Root/home deletion is extremely dangerous |
| find ... -delete | Permanently removes files matching criteria |
| xargs rm -rf | Dynamic input makes targets unpredictable |
| xargs <shell> -c | Can execute arbitrary commands |
| parallel rm -rf | Dynamic input makes targets unpredictable |
| parallel <shell> -c | Can execute arbitrary commands |
| Command Pattern | Why It's Safe |
|---|---|
| git checkout -b branch | Creates new branch |
| git checkout --orphan | Creates orphan branch |
| git restore --staged | Only unstages, doesn't discard |
| git restore --help/--version | Help/version output |
| git branch -d | Safe delete with merge check |
| git clean -n / --dry-run | Preview only |
| git push --force-with-lease | Safe force push |
| rm -rf /tmp/... | Temp directories are ephemeral |
| rm -rf /var/tmp/... | System temp directory |
| rm -rf $TMPDIR/... | User's temp directory |
| rm -rf ./... (within cwd) | Limited to current working directory |
When a destructive command is detected, the plugin blocks the tool execution and provides a reason.
Example output:
BLOCKED by Safety Net
Reason: git checkout -- discards uncommitted changes permanently. Use 'git stash' first.
Command: git checkout -- src/main.py
If this operation is truly needed, ask the user for explicit permission and have them run the command manually.
You can manually test the hook by attempting to run blocked commands in Claude Code:
# This should be blocked
git checkout -- README.md
# This should be allowed
git checkout -b test-branchSee CONTRIBUTING.md for details on how to contribute to this project.
Beyond the built-in protections, you can define your own blocking rules to enforce team conventions or project-specific safety policies.
Tip
Use /set-custom-rules to create custom rules interactively with natural language.
Create .safety-net.json in your project root:
{
"version": 1,
"rules": [
{
"name": "block-git-add-all",
"command": "git",
"subcommand": "add",
"block_args": ["-A", "--all", "."],
"reason": "Use 'git add <specific-files>' instead of blanket add."
}
]
}Now git add -A, git add --all, and git add . will be blocked with your custom message.
Config files are loaded from two scopes and merged:
- User scope:
~/.cc-safety-net/config.json(always loaded if exists) - Project scope:
.safety-net.jsonin the current working directory (loaded if exists)
Merging behavior:
- Rules from both scopes are combined
- If the same rule name exists in both scopes, project scope wins
- Rule name comparison is case-insensitive (
MyRuleandmyruleare considered duplicates)
This allows you to define personal defaults in user scope while letting projects override specific rules.
If no config file is found in either location, only built-in rules apply.
| Field | Type | Required | Description |
|---|---|---|---|
version |
integer | Yes | Schema version (must be 1) |
rules |
array | No | List of custom blocking rules (defaults to empty) |
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Unique identifier (letters, numbers, hyphens, underscores; max 64 chars) |
command |
string | Yes | Base command to match (e.g., git, npm, docker) |
subcommand |
string | No | Subcommand to match (e.g., add, install). If omitted, matches any. |
block_args |
array | Yes | Arguments that trigger the block (at least one required) |
reason |
string | Yes | Message shown when blocked (max 256 chars) |
- Commands are normalized to basename (
/usr/bin/git→git) - Subcommand is the first non-option argument after the command
- Arguments are matched literally (no regex, no glob), with short option expansion
- A command is blocked if any argument in
block_argsis present - Short options are expanded:
-Apmatches-A(bundled flags are unbundled) - Long options use exact match:
--all-filesdoes NOT match--all - Custom rules only add restrictions—they cannot bypass built-in protections
- Short option expansion:
-Cfoois treated as-C -f -o -o, not-C foo. Blocking-fmay false-positive on attached option values.
{
"version": 1,
"rules": [
{
"name": "block-npm-global",
"command": "npm",
"subcommand": "install",
"block_args": ["-g", "--global"],
"reason": "Global npm installs can cause version conflicts. Use npx or local install."
}
]
}{
"version": 1,
"rules": [
{
"name": "block-docker-system-prune",
"command": "docker",
"subcommand": "system",
"block_args": ["prune"],
"reason": "docker system prune removes all unused data. Use targeted cleanup instead."
}
]
}{
"version": 1,
"rules": [
{
"name": "block-git-add-all",
"command": "git",
"subcommand": "add",
"block_args": ["-A", "--all", ".", "-u", "--update"],
"reason": "Use 'git add <specific-files>' instead of blanket add."
},
{
"name": "block-npm-global",
"command": "npm",
"subcommand": "install",
"block_args": ["-g", "--global"],
"reason": "Use npx or local install instead of global."
}
]
}Custom rules use silent fallback error handling. If your config file is invalid, the safety net silently falls back to built-in rules only:
| Scenario | Behavior |
|---|---|
| Config file not found | Silent — use built-in rules only |
| Empty config file | Silent — use built-in rules only |
| Invalid JSON syntax | Silent — use built-in rules only |
| Missing required field | Silent — use built-in rules only |
| Invalid field format | Silent — use built-in rules only |
| Duplicate rule name | Silent — use built-in rules only |
Important
If you add or modify custom rules manually, always validate them with the /verify-custom-rules slash command.
When a custom rule blocks a command, the output includes the rule name:
BLOCKED by Safety Net
Reason: [block-git-add-all] Use 'git add <specific-files>' instead of blanket add.
Command: git add -A
By default, unparseable commands are allowed through. Enable strict mode to fail-closed
when the hook input or shell command cannot be safely analyzed (e.g., invalid JSON,
unterminated quotes, malformed bash -c wrappers):
export SAFETY_NET_STRICT=1Paranoid mode enables stricter safety checks that may be disruptive to normal workflows. You can enable it globally or via focused toggles:
# Enable all paranoid checks
export SAFETY_NET_PARANOID=1
# Or enable specific paranoid checks
export SAFETY_NET_PARANOID_RM=1
export SAFETY_NET_PARANOID_INTERPRETERS=1Paranoid behavior:
- rm: blocks non-temp
rm -rfeven within the current working directory. - interpreters: blocks interpreter one-liners like
python -c,node -e,ruby -e, andperl -e(these can hide destructive commands).
The guard recursively analyzes commands wrapped in shells:
bash -c 'git reset --hard' # Blocked
sh -lc 'rm -rf /' # BlockedDetects destructive commands hidden in Python/Node/Ruby/Perl one-liners:
python -c 'import os; os.system("rm -rf /")' # BlockedBlock messages automatically redact sensitive data (tokens, passwords, API keys) to prevent leaking secrets in logs.
All blocked commands are logged to ~/.cc-safety-net/logs/<session_id>.jsonl for audit purposes:
{"ts": "2025-01-15T10:30:00Z", "command": "git reset --hard", "segment": "git reset --hard", "reason": "...", "cwd": "/path/to/project"}Sensitive data in log entries is automatically redacted.
MIT