Last week, I typed rm Claude.md and watched it disappear. Three layers of defense. A deny list. A hook script with regex validation. A .claudeignore file. All configured, all running. All three failed simultaneously.

This is the story of how I learned that the number of security layers doesn’t matter if any one of them has a gap — and how a single character difference in configuration made all the difference. It’s also a wake-up call for anyone running Claude Code on production projects with sensitive files.


Why I Needed Security in the First Place

I work on a Kotlin Multiplatform project — shared business logic across Android and iOS, with a Node.js backend handling APIs and real-time services. I recently started using Claude Code to accelerate development. Refactoring shared modules, generating platform-specific implementations, scaffolding API endpoints. The productivity gains were real and immediate.

But here’s the reality: my project has sensitive files everywhere. API keys in .env and local.properties. Firebase credentials in google-services.json. JWT signing secrets in the backend’s config/ folder. Database connection strings. Proprietary encryption logic in the shared KMP module. I couldn’t just hand my entire codebase to an AI tool without safeguards.

So I built what I thought was bulletproof defense.


The “Bulletproof” Setup

Layer 1: Deny List (settings.json)

I configured Claude Code’s permission system to explicitly deny dangerous commands:

{
"permissions": {
"deny": [
"Bash(rm -rf *)",
"Bash(rm -fr *)",
"Bash(sudo *)",
"Bash(git push --force *)",
"Bash(git reset --hard *)"
]
}
}

Layer 2: Pre-execution Hook Script

A bash script that runs before every command, checking for dangerous patterns with regex:

.claude/hooks/block-dangerous-commands.sh
#!/bin/bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
command=$(echo "$input" | jq -r '.tool_input.command // ""')
if [ "$tool_name" != "Bash" ]; then
exit 0
fi
# Check for rm -rf (with various flag orderings)
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|...|-rf|-fr)\b'; then
echo '{"error": "BLOCKED: rm -rf is not allowed."}' >&2
exit 2
fi
exit 0

Layer 3: .claudeignore

Preventing Claude Code from reading sensitive files:

local.properties
google-services.json
*.keystore
**/security/impl/
.env
.env.*
backend/config/secrets.json
backend/.env

Three layers. I felt untouchable.


The Test That Broke Everything

During a routine refactoring session, I asked Claude Code to clean up some unused files. It decided a markdown file wasn’t needed anymore. The conversation went like this:

❯ rm Claude.md

Claude Code asked for permission to run rm /Users/ethannguyen/Data/WorkspaceAI/UIProject/CLAUDE.md. I clicked Yes.

The file was gone. No warning from the deny list. No block from the hook script. No protection from .claudeignore. All three layers failed simultaneously.

My reaction: Wait, what? I have THREE security layers. How?


Why All Three Layers Failed

Let me walk through each layer and explain exactly why it didn’t catch this.

Layer 1 Failed: The Deny List Was Too Specific

My deny rule was:

"Bash(rm -rf *)"

The actual command was:

Terminal window
rm Claude.md

See the problem? The deny list only matched rm -rf — the recursive force delete. A simple rm with no flags? Passed right through. The pattern Bash(rm -rf *) is looking for the literal string rm -rf followed by anything. The command rm Claude.md doesn’t contain -rf anywhere.

The mistake: I was protecting against the catastrophic scenario (rm -rf /) while leaving the door wide open for everyday file deletion.

Layer 2 Failed: The Hook Regex Was Equally Narrow

My hook script’s regex:

Terminal window
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|...|-rf|-fr)\b'; then

This regex specifically looks for rm followed by flags containing both r and f. The command rm Claude.md has no flags at all — just rm followed by a filename. The regex didn’t match.

The mistake: Same root cause. I designed the hook to catch rm -rf variations, not rm itself.

Layer 3 Was Irrelevant

.claudeignore prevents Claude Code from reading files. It has zero effect on deleting files. The file CLAUDE.md wasn’t even in the ignore list — but even if it were, .claudeignore wouldn’t have prevented deletion.

The mistake: Misunderstanding what .claudeignore actually protects against. It’s a read barrier, not a write/delete barrier.

The Human Factor

And then there’s me. Claude Code did ask for permission. It showed me the exact command: Bash(rm /Users/ethannguyen/.../CLAUDE.md). I clicked Yes without fully processing what I was approving.

During a long refactoring session with dozens of permission prompts, approval fatigue sets in. You start clicking Yes automatically. This is exactly the scenario where security configuration needs to save you from yourself.


The Fix: One Character That Changed Everything

The fix was embarrassingly simple. In the deny list:

"Bash(rm -rf *)"
"Bash(rm *)"

That’s it. Changing rm -rf to just rm. Now the deny rule matches any command that starts with rm, regardless of flags.

The updated hook script follows the same principle:

Terminal window
# BEFORE: Only catches rm with -rf flags
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|...)\b'; then
# AFTER: Catches ALL rm commands
if echo "$command" | grep -qE '\brm\s+'; then

After applying the fix, I tested the exact same operation:

❯ rm Claude_CONTEXT.md

Blocked. The red error text says it all: “Error: Permission to use Bash with command rm has been denied.”

Claude Code then searched for the file, confirmed it doesn’t exist in the project, and stopped. The system worked exactly as intended.


The Complete Before vs. After

❌ BEFORE (Vulnerable)

{
"permissions": {
"deny": [
"Bash(rm -rf *)",
"Bash(rm -fr *)",
"Bash(rm -r *)",
"Bash(sudo *)"
]
}
}

What it blocked: rm -rf anything, rm -r folder/, sudo commands

What it missed: rm file.txt, rm *.kt, rm -f file.txt, unlink file

Terminal window
# Hook regex — only catches recursive+force
grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|...|-rf|-fr)\b'

✅ AFTER (Secure)

{
"permissions": {
"deny": [
"Bash(rm *)",
"Bash(rmdir *)",
"Bash(unlink *)",
"Bash(shred *)",
"Bash(sudo *)",
"Bash(git push --force *)",
"Bash(git push -f *)",
"Bash(git reset --hard *)",
"Bash(git clean *)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(kill *)"
]
}
}

What it blocks: ALL file deletion commands, regardless of flags or arguments.

Terminal window
# Hook regex — catches ANY rm command
grep -qE '\brm\s+'

The Real-World Danger: Refactoring Sessions

Here’s why this matters beyond my test case. During refactoring, Claude Code might decide to delete files for legitimate-sounding reasons:

  • “This file is unused, removing it.” — Maybe it’s unused in the module Claude can see, but it’s referenced elsewhere.
  • “Removing old implementation before creating new one.” — What if the new implementation fails? The old one is gone.
  • “Cleaning up generated files.” — Claude misidentifies a hand-written file as generated.
  • “This file has compilation errors, removing it.” — The errors might be from a missing dependency, not a bad file.

In every case, Claude Code believes it’s being helpful. And in a long session with approval fatigue, you might let it happen.

The correct approach: Claude Code should never have the ability to delete files. If a file needs to be deleted, you do it manually in your terminal. This is a one-way door that AI shouldn’t be able to open.


The Complete Defense Setup

Here’s my final, tested configuration:

.claude/settings.json

{
"permissions": {
"allow": [
"Edit",
"MultiEdit",
"Read",
"Bash(./gradlew *)",
"Bash(cd *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(mkdir *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(find *)",
"Bash(grep *)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(git log*)",
"Bash(git add *)",
"Bash(git commit *)"
],
"deny": [
"Bash(rm *)",
"Bash(rmdir *)",
"Bash(unlink *)",
"Bash(shred *)",
"Bash(sudo *)",
"Bash(chmod 777 *)",
"Bash(chmod +s *)",
"Bash(mkfs *)",
"Bash(dd *)",
"Bash(git push --force *)",
"Bash(git push -f *)",
"Bash(git reset --hard *)",
"Bash(git clean *)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(ssh *)",
"Bash(scp *)",
"Bash(kill *)",
"Bash(pkill *)",
"Bash(killall *)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-dangerous-commands.sh"
}
]
}
]
}
}

.claude/hooks/block-dangerous-commands.sh

#!/bin/bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
command=$(echo "$input" | jq -r '.tool_input.command // ""')
if [ "$tool_name" != "Bash" ]; then
exit 0
fi
# Block ALL file deletion
if echo "$command" | grep -qE '\brm\s+'; then
echo '{"error": "BLOCKED: rm is not allowed. Delete files manually."}' >&2
exit 2
fi
if echo "$command" | grep -qE '\b(rmdir|unlink|shred)\s+'; then
echo '{"error": "BLOCKED: File deletion is not allowed."}' >&2
exit 2
fi
# Block sudo
if echo "$command" | grep -qE '^\s*sudo\s+'; then
echo '{"error": "BLOCKED: sudo not allowed."}' >&2
exit 2
fi
# Block destructive git
if echo "$command" | grep -qE 'git\s+(push\s+.*(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)'; then
echo '{"error": "BLOCKED: Destructive git operation."}' >&2
exit 2
fi
# Block network exfiltration
if echo "$command" | grep -qE '\b(curl|wget|ssh|scp)\s+'; then
echo '{"error": "BLOCKED: Network command not allowed."}' >&2
exit 2
fi
# Block dangerous permissions
if echo "$command" | grep -qE 'chmod\s+(777|666|\+s)'; then
echo '{"error": "BLOCKED: Dangerous permission change."}' >&2
exit 2
fi
# Block process killing
if echo "$command" | grep -qE '\b(kill|killall|pkill)\s+'; then
echo '{"error": "BLOCKED: Process kill not allowed."}' >&2
exit 2
fi
# Block secret file access
if echo "$command" | grep -qE 'cat\s+.*(\.env|local\.properties|secrets|keystore|google-services)'; then
echo '{"error": "BLOCKED: Secret file access."}' >&2
exit 2
fi
exit 0

The Fourth Layer: Git

No configuration is perfect. Always have git as your final safety net:

Terminal window
# Before every Claude Code session
git add -A && git commit -m "checkpoint before Claude Code"
# If anything goes wrong
git checkout . # Restore all modified files
git checkout -- file # Restore specific file

Key Takeaways

1. Security is only as strong as its weakest configuration. Three layers with the same gap equals zero protection for that specific attack vector.

2. Test your actual threat model, not just the worst case. I tested rm -rf / (catastrophic) but not rm file.txt (common). The common case is what actually bit me.

3. Prefer broad deny rules over specific ones. Bash(rm *) is always safer than Bash(rm -rf *). You can always whitelist specific safe patterns if needed.

4. Approval fatigue is real. During long sessions, you will click Yes without reading. Your configuration needs to protect you from this.

5. AI tools will delete files with the best intentions. During refactoring, Claude Code genuinely believes it’s helping when it removes “unused” files. The problem is it doesn’t always have full context about what’s truly unused.

6. Git is your real safety net. Configuration prevents accidents. Git recovers from them. Always commit before letting AI tools modify your codebase.


This happened on a real production project. The file I lost was recoverable (it was a markdown file, and I had the content in my conversation history). But it could have been a critical Kotlin shared module, a Node.js route handler, or a Gradle configuration. The lesson cost me a few minutes of recovery time. Yours might cost more.

If you’re using Claude Code on production projects, take 10 minutes to properly configure your deny list and hooks. And test them — not just with rm -rf, but with rm yourfile.txt.


Want to go deeper? The Claude Code Mastery course covers all of this and more — 16 phases, 64 modules, from foundation to full-auto multi-agent workflows. Phases 1-3 are free.

Get the free Claude Code Cheat Sheet — 50+ commands in a single PDF — when you join the newsletter.