How to make Claude Code send files to the trash bin to prevent accidental deletions

Birgit aka. Paulchen working with Claude

The full hook code is on GitHub: bph/claude-rm-to-trash

Claude Code is powerful — sometimes a little too powerful. By default, when it runs rm it permanently deletes files. One wrong move and your work is gone. I wanted a safety net: instead of deleting, send files to the macOS Trash so I can recover them if needed.

Here’s how I did it with a Claude Code PreToolUse hook and Claude’s help


What is a PreToolUse hook?

Claude Code supports hooks — shell scripts that run automatically before (or after) a tool is used. A PreToolUse hook can inspect the tool being called, and either allow it, block it, or rewrite the input before it runs.

That last part is the key: we can intercept a Bash call containing rm and silently swap it for /usr/bin/trash.


Step 1: Check that /usr/bin/trash exists

macOS ships with /usr/bin/trash — a command-line tool that moves files to the Trash. Test it:

echo "test" > /tmp/test.txt /usr/bin/trash /tmp/test.txt

Check your Trash folder — the file should be there. If /usr/bin/trash is missing on your system, install an alternative:

brew install trash

And update the path in the script below accordingly.


Step 2: Create the hook script

Create ~/.claude/hooks/rm-to-trash.py:

#!/usr/bin/env python3
"""
Claude Code PreToolUse hook: redirect rm to /usr/bin/trash.
"""
import sys
import json
import re
def allow(cmd=None):
out = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
}
}
if cmd is not None:
out["hookSpecificOutput"]["updatedInput"] = {"command": cmd}
print(json.dumps(out))
def rewrite_rm(command):
"""Replace standalone rm [flags] with /usr/bin/trash, stripping flags."""
pattern = r'(?<![a-zA-Z0-9_/])rm((?:\s+-[a-zA-Z]+)*)(\s+|$)'
def replacer(m):
return "/usr/bin/trash" + m.group(2)
return re.sub(pattern, replacer, command)
def main():
data = json.load(sys.stdin)
tool = data.get("tool_name", "")
command = data.get("tool_input", {}).get("command", "")
if tool == "Bash" and re.search(r'(?<![a-zA-Z0-9_/])rm(\s+-[a-zA-Z]+)*(\s+|$)', command):
allow(rewrite_rm(command))
else:
allow()
if __name__ == "__main__":
main()

Then create a thin shell wrapper at ~/.claude/hooks/rm-to-trash.sh:

#!/bin/bash
exec python3 "$(dirname "$0")/rm-to-trash.py"

Make both executable:

chmod +x ~/.claude/hooks/rm-to-trash.sh
chmod +x ~/.claude/hooks/rm-to-trash.py

Step 3: Register the hook in Claude Code settings

Open ~/.claude/settings.json and add a hooks section:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/rm-to-trash.sh"
}
]
}
]
}
}

If you already have other settings in the file, add the hooks block alongside them.


How it works

When Claude Code runs any Bash command, the hook fires first. The hook script receives the command as JSON on stdin:

{
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf ./old-build"
}
}

The Python script detects rm as a standalone word (skipping things like npm or trim), strips any flags (-rf-f, etc. — trash doesn’t need them), and returns the rewritten command:

{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"command": "/usr/bin/trash ./old-build"
}
}
}

Claude Code then runs /usr/bin/trash ./old-build instead of the original rm. The file lands in your Trash and can be recovered from Finder.


What gets intercepted

CommandRewritten to
rm file.txt/usr/bin/trash file.txt
rm -rf ./dir/usr/bin/trash ./dir
rm -f file && echo done/usr/bin/trash file && echo done
npm installunchanged
git rm file.txt/usr/bin/trash file.txt
git rm --cached file/usr/bin/trash file (flags stripped, file stays)

Caveats

  • git rm: The hook intercepts git rm too — including git rm --cached. For --cached, this is actually safer: the file stays on disk in Trash instead of being untracked and left in place. If you need the git index change, run git rm manually in your terminal outside Claude Code.
  • find -exec rm: The rm inside a find -exec clause will also be rewritten. Trash handles multiple files fine, so this works correctly.
  • This is global: The hook is registered in ~/.claude/settings.json, so it applies in every project and session.

Verify it’s working

Create a test file and ask Claude Code to delete it:

echo "test" > /tmp/test-claude.txt

Then in Claude Code: “delete /tmp/test-claude.txt”

Check your Trash — the file should be there instead of permanently gone.


It bit me during its own setup

While publishing this hook to GitHub, I asked Claude Code to remove the blog draft from the repo with:

git rm --cached blog-draft.md

The hook immediately intercepted it and turned it into:

git /usr/bin/trash --cached blog-draft.md

Which failed with: git: '/usr/bin/trash' is not a git command

The fix was to use git update-index --force-remove instead, which achieves the same result without the word rm. A good reminder that the hook is genuinely global — it catches everything, including commands you type yourself through Claude Code.

BTW, you can ask Claude to implement it for you, too.

Claude documentation on Hooks

Automate workflows with hooks

I code for a purpose
I code for a purpose
@pauli@icodeforapurpose.com

Personal tech blog of Birgit Pauli-Haack

54 posts
3 followers

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.