I run Claude Code almost around the clock, mostly unattended. It has shell access, so the thing I fear most is an accidental wide delete. I stop dangerous deletes two ways: a hook of my own that blocks them just before they run, and Claude Code's own permission system. Two nets, I thought.
Then I found a hole in one net. And a few days later I learned the other net — Claude Code's official permission check — had a hole of exactly the same shape. This post is a record of what I measured with my own exit codes, placed next to the fact that the same gap existed in the official tool.
My own hook let 2>/dev/null walk right through
I publish a free hook collection, cc-safe-setup, and its core has a hook that blocks dangerous deletes. I fed it each command on standard input and measured whether it stopped (exit code 2) or let it pass (0). No dangerous command was ever executed. I used the version you actually get today from npx cc-safe-setup (the one published on npm, 29.8.0, as of July 2026). Hooks get updated, so treat this as the behavior at the time I checked.
| Command | Exit code | Result |
|---|---|---|
rm -rf ~ |
2 | blocked |
rm -rf ~ 2>/dev/null |
0 | passes |
rm -rf "$HOME" 2>/dev/null; rm -f a |
0 | passes |
The first row is what you'd hope: wiping your home directory with rm -rf ~ is stopped. But the second row — add a single 2>/dev/null after it and it goes through. Same delete, one extra "throw away the error output" token, and the net is defeated. The third row is not hypothetical: someone meant to delete a temp directory, a variable resolved to their real home, and they lost their documents, images and settings — the actual accident report (GitHub issue #75859) is exactly this shape.
Why does it slip? A net that judges danger by matching text tries to decide, character by character, "where does the delete target end?" 2>/dev/null is, to the shell, a separate instruction (redirect stderr to nowhere), but a text-scanning net loses its place there. What the shell actually does and what the net thinks the text says drift apart. A redirect symbol drives a wedge into exactly that gap.
The same blind spot was in the official permission check
If this were only about my own hook, you could say "your hook is just sloppy" and move on. But then I read the Claude Code changelog for 2.1.214, and stopped. Among the fixes in that release (paraphrasing the entry):
Fixed Bash permission checks to fail closed on file-descriptor redirect forms that bash parses differently than the permission analyzer.
In other words: a redirect form where the shell's interpretation and the permission net's interpretation diverge and slip through — the very same blind spot I'd found in my own hook existed in Claude Code's official permission check, up to this version.
And in 2.1.214 that redirect bypass wasn't the only one. The same release lists, side by side:
- Commands over 10,000 characters were misjudged (they now always prompt instead of running automatically)
- zsh variable subscripts and modifiers inside
[[ ]]were treated as inert text (these now prompt) - Certain
helpandmancommands were auto-approved even though they could run unsafe options or command substitutions - A permission-check bypass in a certain Windows PowerShell session
Most of them exploit the same thing: a gap between judging by surface text and how the shell actually interprets it. In a single release, five holes of this kind were closed at once. The structural weakness — a text-matching gate can be walked around just by rewriting the command — showed up in the official permission check and in my hook alike.
So what do you do about it
First, upgrade Claude Code to 2.1.214 or later. The permission-check bypasses above are closed in that release. It's the cheapest, most certain single move, and it's free.
But don't stop there. Five holes closed in one release is also proof that a gate which leans on surface text has as many holes as there are ways to write a command. The next phrasing can slip through again. So don't hand an irreversible operation to a single net like the permission check. Defend in layers.
-
Look before you delete. Get into the habit of listing what will be deleted first. With
find, run it without the delete action and just see the list. Withgit clean, do a dry run before you commit. - Assume it'll be deleted; make an escape hatch. Before a dangerous operation, cut a branch, commit, take a backup. If a net is defeated, what's lost stays inside what you can restore.
-
Name the target explicitly; don't lean on variables. The core of #75859 was leaving the delete target to a variable (
$HOME). Variables resolve to values you didn't expect. If you know the subdirectory, target it by relative name —rm -rf ./build— one thing only. - Keep configuration, execution and recovery independent. A pre-execution net (the permission check, or a hook like mine) is one layer of many. Layer a config that refuses to allow the dangerous tool call at all, a layer that stops it just before execution, and a recovery layer that lets you restore even if both are bypassed — independently.
To be honest: my own free hook, cc-safe-setup, still lets that 2>/dev/null form through, as the table above shows (it does stop the plain dangerous delete). So I won't tell you "install this and you're safe." Use it as one net that stops the most common form, and layer on top of it. A single net that leans on surface text — official or homegrown — can be walked around by rewriting the command. Only layered defense, built assuming that, protects your data.
Two free things if you want them:
- The config-and-hooks that stop this class of accident just before execution:
npx cc-safe-setup(MIT). - A free monthly note on what silently broke in Claude Code that month, with the settings to prevent it: Claude Code Safety Brief.
Top comments (0)