1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code に「フォルダごと消す」を打たせない PreToolUse フック(rm -r の変数が空になる事故・Remove-Item -Recurse・find -delete)

1
Posted at

この記事を読み終えると、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 $?

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?