TL;DR — Three security layers all failed to stop a plain rm Claude.md because they were each configured to block rm -rf, not rm. The fix was a single character change: "Bash(rm -rf *)""Bash(rm *)". Test your actual threat model, not just the catastrophic one. Jump to the fix →

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 Do You Need Security Configuration for Claude Code?

Claude Code can read, edit, and execute commands across your entire project. Without explicit deny rules, it has access to every file, including secrets, credentials, and configs you never intended to expose to an automated tool.

Key insight: Claude Code’s permission system grants filesystem and shell access by default when you approve commands. Without a configured deny list, it can read credential files, write to any path, and execute destructive shell commands. The only guaranteed protection is explicit deny rules in settings.json — relying on careful prompt writing or manual review is not sufficient during long sessions when approval fatigue sets in.

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.


What Did the “Bulletproof” Security Setup Look Like?

Three independent layers should catch anything that slips through, right? In practice, each layer I built shared the same blind spot: all three were designed to block catastrophic deletion (rm -rf), not everyday file removal.

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.

Key insight: Three independent security layers — a deny list, a regex hook script, and a .claudeignore file — all failed to block rm Claude.md because they shared the same blind spot: each was designed to catch catastrophic deletion (rm -rf) rather than everyday file removal. Layered security only provides depth when each layer covers different threat vectors, not when all layers target the same worst-case scenario.


What Happened When All Three Security Layers Failed?

A simple rm Claude.md command bypassed all three layers and deleted the file. The deny list, the hook script, and .claudeignore each failed for different reasons, but all three shared the same root cause: they were configured for worst-case scenarios, not the common case.

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 Did All Three Security Layers Fail?

Each layer failed for a distinct reason: the deny list only matched rm -rf, the hook regex required both -r and -f flags, and .claudeignore only controls read access, not write or delete. None of them were built to stop a plain rm filename command.

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.

Key insight: Each of Claude Code’s three built-in safeguards protects a different surface: the deny list blocks specific command patterns, .claudeignore blocks file reads, and the permission prompt gives you manual approval control. None of them cross-protect each other’s surface. A file can be in .claudeignore and still be deleted; a command can be approved through the permission prompt and still be blocked by the deny list. Understanding what each layer actually does is prerequisite to configuring them correctly.

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.


What One-Character Fix Blocked All File Deletions?

Changing "Bash(rm -rf *)" to "Bash(rm *)" in the deny list blocks all rm commands regardless of flags. The same principle applied to the hook regex reduced the entire vulnerability to a two-character edit.

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.”

Key insight: Changing "Bash(rm -rf *)" to "Bash(rm *)" in Claude Code’s deny list is a two-character edit that blocks all rm commands regardless of flags or arguments. The deny list uses glob-style pattern matching: Bash(rm *) matches any Bash call where the command starts with rm followed by a space and anything else. This single change eliminates the entire class of file deletion vulnerabilities.

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


What Does the Complete Before vs. After Configuration Look Like?

❌ 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+'

Why Are Refactoring Sessions the Most Dangerous Time for File Deletion?

During active refactoring, Claude Code makes file deletion decisions based on partial context. A 2024 study on AI coding assistants found that tools given broad filesystem access will attempt to clean up files in roughly 30% of refactoring sessions, often based on incomplete visibility into cross-module dependencies.

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.

Key insight: During active refactoring, Claude Code will attempt to delete files in roughly 30% of sessions based on observations of AI coding assistants given broad filesystem access. The rationale always sounds reasonable — unused file, old implementation, generated artifact — but Claude’s visibility into cross-module dependencies is incomplete. File deletion should be a human-only action reserved for the terminal, not an AI-delegated command.

Try it now: Open your .claude/settings.json right now and check whether your deny list says "Bash(rm -rf *)" or "Bash(rm *)". If it’s the former, make the change. Then test it: ask Claude to run rm testfile.txt and verify it gets blocked.


What Does the Complete Claude Code Defense Setup Look Like?

The final configuration combines an explicit allow list (only the commands Claude needs), a broad deny list (all deletion and network commands), and a hook script as a second check. Allow lists are safer than deny lists alone because they block anything not explicitly permitted.

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

Key insight: Combining an explicit allow list with a broad deny list is safer than a deny list alone. Claude Code evaluates allow rules before deny rules, so "Bash(rm ./build/*)" in the allow list takes precedence over "Bash(rm *)" in the deny list for build directory cleanup. An allow list that enumerates only the commands Claude genuinely needs — gradlew, ls, cat, mkdir, git add, git commit — blocks everything not explicitly permitted, providing defense-in-depth that a deny list alone cannot.

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

What Are the Key Takeaways?

Six principles stood out from this incident. The most important: security configuration should be broad by default, and file deletion should never be something an AI tool can do without manual confirmation.

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.

Key insight: Security configuration for AI coding tools should be broad by default and narrowed only when necessary — the inverse of how most developers initially configure it. Starting with "Bash(rm *)" and allowing specific exceptions is safer than starting with "Bash(rm -rf *)" and trying to enumerate all dangerous variants. Broad deny rules are easier to reason about and harder to accidentally bypass.

Get weekly Claude Code tips — One practical tip every week. No fluff, no spam. Subscribe to AI Developer Weekly →


FAQ

Does .claudeignore protect files from being deleted? No. .claudeignore only prevents Claude Code from reading files. It has no effect on write or delete operations. A file in .claudeignore can still be deleted if Claude Code issues an rm command and you approve it.

Can I allow some rm commands while blocking others? Yes. Claude Code evaluates allow rules before deny rules. You can add specific patterns like "Bash(rm ./build/*)" to the allow list while keeping "Bash(rm *)" in the deny list. The more specific allow rule takes precedence.

What’s the difference between the deny list and the hook script? Do I need both? They serve as independent layers. The deny list is evaluated by Claude Code before the command runs. The hook script is a custom script you control. Having both means a misconfiguration in one doesn’t leave you exposed. For critical projects, keep both.

Will blocking rm break normal Claude Code workflows? Rarely. Claude Code’s primary job is reading and editing files, not deleting them. In practice, blocking rm causes Claude to propose deletions in text rather than execute them, which is the safer behavior. You can always delete files manually in your terminal.

What should I do if Claude Code already deleted a file? If you committed before the session (git add -A && git commit -m "checkpoint"), run git checkout HEAD -- path/to/file to restore it. If not, check your editor’s local history (VS Code has a built-in timeline) or your OS trash. Going forward, always checkpoint before AI-assisted sessions.


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.