この記事を読み終えると、Claude Code が打つ rm -rf などのフォルダごと消すコマンドを、一時フォルダの中以外では止めるフックが置けます。
根拠は、手元で使っているフックに、2026-11-05 にコマンドを16通り渡して確かめた結果です。Mac(zsh)と Windows(PowerShell)の両方で使っています。
何に困ったか
ある人の報告で、AI に一時フォルダの片づけを頼んだら、コマンドの中の変数が空に置き換わって、ホームフォルダごと消えたという話を読みました。
rm -rf "$WORK_DIR/" # WORK_DIR が空だと rm -rf "/" と同じ
CLAUDE.md には「承認なしでファイルを消さない」と書いてあります。ただ、文章の決まりは、うっかりを止めてくれません。
しかも、変数が空になる事故は、打った本人(Claude)も気づいていないので、「気をつけて」では防げません。
そこで、消すコマンドそのものを、ツールを動かす前に機械的に止めることにしました。
環境
- Claude Code(CLI と VS Code の拡張)、Python 3
- Mac では Bash の道具、Windows では PowerShell の道具に同じフックを付ける
設定: settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|PowerShell",
"hooks": [
{ "type": "command", "command": "python3 ~/.claude/hooks/delete-guard.py", "timeout": 10 }
]
}
]
}
}
matcher に PowerShell も入れておくと、Windows の PowerShell の道具でも同じフックが動きます。
決めたこと: 通すのは一時フォルダの中だけ
「何を止めるか」を並べると、きりがありません。逆に通す場所を1つに決めました。
- 通す:
/tmp/・/private/tmp/・/var/folders/(Mac の一時フォルダ)の中を、フォルダごと消す - 止める: それ以外の場所をフォルダごと消すコマンド全部
- 見ない: ファイル1つを消す
rm file.txt(フォルダごとではないので、被害が小さい)
一時フォルダの中でも、次の3つは止めます。
- 消す場所に
$が入っている(変数が空になると、思っていない場所になる) -
*が入っている -
..が入っている(/tmp/../Usersのように一時フォルダの外に出られる)
フックの中身
import json, re, shlex, sys
data = json.load(sys.stdin)
cmd = (data.get('tool_input', {}) or {}).get('command', '') or ''
# ヒアドキュメントの中身(ファイルに書く文)は調べない
body = re.sub(r'<<\s*[\'"]?(\w+)[\'"]?.*?^\1$', '', cmd, flags=re.S | re.M)
SAFE = ('/tmp/', '/private/tmp/', '/var/folders/')
def stop(why):
sys.stderr.write(f'止めました: {why}\n'
'フォルダごと消すコマンドは、一時フォルダの中だけ通します。'
'変数が空になると、思っていない場所が消えます。'
'消す必要があるなら、消す場所と理由を伝えて許可をもらってください。\n')
sys.exit(2)
# Windows(PowerShell・cmd)の再帰削除
if re.search(r'\bRemove-Item\b[^;|&\n]*-(?:Recurse|r)\b', body, re.I) or \
re.search(r'\b(?:del|erase|rd|rmdir)\b[^;|&\n]*\s/s\b', body, re.I):
stop('Windows のフォルダごとの削除(Remove-Item -Recurse / del /s / rmdir /s)')
# rm -r / -R / --recursive と find -delete。; && | $( ) で区切って1つずつ見る
for seg in re.split(r'[;&|\n]+|\$\(|`', body):
seg = seg.strip()
if re.search(r'\bfind\b.*\s-delete\b', seg):
try:
words = shlex.split(seg, posix=True)
except ValueError:
words = seg.split()
i = words.index('find') if 'find' in words else -1
root = words[i + 1] if i >= 0 and i + 1 < len(words) else '.'
if not root.startswith(SAFE):
stop(f'find -delete({root})')
m = re.search(r'(?:^|\s)(?:sudo\s+)?rm\s+(.*)', seg)
if not m:
continue
raw = m.group(1)
try:
args = shlex.split(raw, posix=True)
except ValueError:
args = raw.split()
flags = [a for a in args if a.startswith('-') and a != '--']
recursive = any(a == '--recursive' or (not a.startswith('--') and re.search(r'[rR]', a)) for a in flags)
if not recursive:
continue
targets = [a for a in args if not a.startswith('-')]
rawt = [t for t in re.split(r'\s+', raw) if t and not t.startswith('-')]
if not targets:
stop('rm -r(消す場所が空)')
for t, r in zip(targets, rawt + [''] * len(targets)):
if '$' in r or '*' in t or '..' in t or not t.startswith(SAFE):
stop(f'rm -r {r or t}')
sys.exit(0)
ポイントは3つです。
-
消す場所に
$があれば止める。フックが受け取るのは展開する前のコマンドなので、変数の中身は分かりません。分からない場所は消させない、と割り切りました -
-rf・-fr・-R・--recursiveをまとめて拾う。-で始まる引数のどれかにrかRがあれば、フォルダごとの削除とみなします -
コマンドを
;&&|$(で区切ってから見る。git status && rm -rf $DIRのように、前に別のコマンドがあっても拾えます
動かして確かめた結果
コマンドを JSON にして標準入力に渡し、終了コードを見ました(2026-11-05、Windows の Python 3.9)。2 が止まる、0 が通るです。
| コマンド | 結果 |
|---|---|
rm -rf "$TMPDIR_X/" |
2(変数) |
rm -rf /tmp/work123 |
0 |
rm -rf ~/old-project |
2 |
rm -r ./build |
2 |
rm file.txt |
0(フォルダごとではない) |
rm -rf /tmp/../Users |
2(..) |
rm -fr /tmp/* |
2(*) |
rm -rf -- /tmp/a |
0 |
git status && rm -rf $DIR |
2 |
find . -name "*.log" -delete |
2 |
find /tmp/x -name "*.log" -delete |
0 |
Remove-Item -Recurse -Force C:\work\old |
2 |
Remove-Item old.txt |
0 |
rd /s /q build |
2 |
ヒアドキュメントで rm -rf ~/ という文をファイルに書く |
0(書くだけなので通す) |
echo "rm -rf ~/" > memo.txt |
0 |
止まったときは、Claude に「止めました: rm -r ~/old-project」と理由が返り、消す場所と理由を私に聞く動きになります。
つまずき
1. テストのコマンドそのものが止められた
確かめるために、テストのコマンドを並べたシェルの1行を Claude Code に打たせたところ、その1行がこのフックに止められました。
テストの文字列の中に Remove-Item -Recurse が入っていたためです。Windows の判定だけは、区切らずにコマンド全体を正規表現で探しているので、引用符の中の文字にも反応します。
テストの入力は別のファイル(Python のリスト)に書き、そのファイルを実行する形に変えて通しました。
引用符の中まで止まるのは誤検知ですが、消す側に倒れるより安全なので、今はこのままにしています。
2. ヒアドキュメントの中は調べない
ドキュメントや記事のファイルに rm -rf という例を書くことがあります。
そのたびに止まると仕事にならないので、<<EOF ... EOF の中身は、判定の前に取り除いています。
3. これは柵であって壁ではない
止めているのは「よく使う消し方」だけです。Python の shutil.rmtree をスクリプトの中で呼ぶなど、ほかの道はあります。
うっかりの事故を減らすためのもので、わざと消そうとするものは止められません。
向いていない人
- ビルドの成果物(
./buildなど)を毎回消して作り直す作業が多い人。毎回止まるので、そのフォルダを通す場所(SAFE)に足す必要があります - ほかの OS の一時フォルダを使う人。Windows の
%TEMP%はSAFEに入れていないので、フォルダごとの削除は全部止まります
まとめ
- 「承認なしで消さない」は文章ではなく、PreToolUse フック(終了コード 2)で止めた
- 止めるものを並べず、「一時フォルダの中だけ通す」と通す側を決めた
- 一時フォルダの中でも
$・*・..は止める(変数が空になる事故の対策) - Windows の
Remove-Item -Recurse・rd /sも、同じフックで止める
今日の一歩(5分)
手元のフックに、次の1行を渡してみてください。止まらなければ、変数が空になる事故は防げていません。
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf \"$WORK_DIR/\""}}' | python3 ~/.claude/hooks/delete-guard.py; echo $?
参考
- Claude Code の Hooks(PreToolUse、終了コード 2 でツール呼び出しをブロック): https://docs.claude.com/en/docs/claude-code/hooks