grep -rn 'rm -rf' . を実行しようとして、安全フックに止められたことはないだろうか。
検索することは、実行することではない。だがコマンドの文字列を見て判断するフックにとって、
危険なコマンドを「実行する命令」と「話題にしているだけの命令」は、同じ文字の並びに見える。
止まって困るのは、その瞬間の不便だけではない。誤検知を繰り返すフックは外される。
外された時点で、本物の事故も止まらなくなる。
だからフックを入れたら、止まるべきものが止まるかと同じくらい、
止まってはいけないものが通るかを測る必要がある。
この記事は、その測り方を対照8件で示す。手元のフックに10分で当てられる。
測り方——コマンドを実行せずにフックだけを撃つ
Claude Code の PreToolUse フックは、標準入力に JSON を受け取り、終了コード2で拒否する。
つまり JSON を自分で作って渡せば、コマンドを1つも実行せずに判定だけを取り出せる。
fire() { # $1=フックのパス $2=判定させたいコマンド文字列
printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(
printf '%s' "$2" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'
)}}" | bash "$1" >/dev/null 2>&1
[ $? -eq 2 ] && echo "止める" || echo "通す"
}
安全に測れることが大事だ。rm -rf ~ が止まるかを確かめるために rm -rf ~ を打つ必要はない。
対照8件
対照を先に置く。「止める」しか出せない計器で「止めた」を読んでも意味がない。
だから止まるべき2件と、止まってはいけない6件を並べて、両方の答えが出ることを先に見る。
| # | 種別 | コマンド |
|---|---|---|
| 1 | 止まるべき | rm -rf /home/you/scratch-dir |
| 2 | 止まるべき | rm -rf ~/scratch-dir |
| 3 | 止まってはいけない | grep -rn 'rm -rf' ~/notes |
| 4 | 止まってはいけない | echo 'never run rm -rf on your home' >> notes.md |
| 5 | 止まってはいけない | git commit -m 'docs: warn about rm -rf in CLAUDE.md' |
| 6 | 止まってはいけない | python3 -c "keys = ['rm -rf', 'git reset']; print(len(keys))" |
| 7 | 止まってはいけない | jq -n '{cmd: "rm -rf"}' |
| 8 | 止まってはいけない | awk '/rm -rf/ {print}' history.log |
3〜8 は全部、危険なコマンドの綴りを引用符の中に持っているだけで、実際に消すものは何もない。
検索・文書・コミットメッセージ・データの組み立て——
安全について作業していると、この形は毎日出てくる。
文字列で判定するフックに当てた結果
よく見かける形の、正規表現で1行を丸ごと見るだけのフックに当ててみた。
CMD=$(printf %s "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE 'rm\s+-r(f|F)|rm\s+-(f|F)r'; then
echo 'BLOCKED' >&2; exit 2
fi
| 止まるべき2件 | 止まってはいけない6件 | |
|---|---|---|
| 1行を丸ごと見るフック | 2件とも止めた | 6件とも止めた |
本物は全部止まる。無害も全部止まる。
この数字の並びは「厳しすぎるフック」ではなく、判定が効いていないフックを意味する。
grep -qE は行全体を見ているので、rm -rf という4文字が行のどこにあっても同じ結果になる。
引用符の中か外かを、この書き方は一度も見ていない。
直す向き——行全体ではなく「コマンド名の位置」を見る
素直な発想は「引用符の中を判定から外す」だが、これは効かないどころか危ない。
rm -rf "/etc" # 引数を引用した本物。引用符の中を外すと素通りする
外していいのはコマンド名が来る位置に現れた引用だけで、引数の引用ではない。
つまり判定の単位を「行」から「コマンド名」へ下げる。手順は3つになる。
- 区切りで分ける。
;&&||パイプ、$( )の中、forの本体。
1行に複数のコマンドが並ぶのが普通なので、まず分ける - 包みを剥がす。
nice -n 5timeout 30env FOO=1sudosh -cbash -cは、
後ろに本当のコマンドが続く。包みの引数の値まで剥がす(-n 5の5を
コマンド名と読まないように) - 先頭の語だけを見る。ここまで来て初めて
rmかどうかを判定する。
/bin/rmmkfs.ext4のように、パスや点を含む名前も同じ位置に来る
この形にすると、3〜8 は「先頭の語が grep / echo / git / python3 / jq / awk」
なので判定に入らず、1・2 は「先頭の語が rm」なので今までどおり止まる。
実際に当てて確かめた。比べたのは、区切りで分けてから行頭の錨つきで先頭の語を見る書き方
(tr ';&|' '\n' で文へ割り、grep -E '^[[:space:]]*(sudo[[:space:]]+)?rm[[:space:]]+...'
で判定する形)。2026年9月16日に同じ8件を撃った結果がこれだ。
| 止まるべき2件 | 止まってはいけない6件 | |
|---|---|---|
| 1行を丸ごと見るフック | 2件とも止めた | 6件とも止めた |
| 先頭の語を見るフック | 2件とも止めた | 6件とも通した |
止めるべきものの成績は落ちていない。落ちたのは誤検知のほうだけだ。
そのフックは、何を止めないかを言えるか
フックを配る側にも、入れる側にも、同じ問いがある。
- 止めるものの一覧は、たいてい書いてある
- 止めないものの一覧は、ほとんど書かれていない
止めないものを言えないフックは、どこまで信じていいかが分からない道具だ。
上の8件は、その一覧を作り始めるための最小の対照になる。
ここで直感が外れる。「find . -name '*.tmp' -delete や git clean -n も誤爆しやすいはずだ」と
見当をつけて当ててみたら、どちらも通った。
そして同じフックは git clean -fd(本物の削除)も通した。
| コマンド | 1行を丸ごと見るフック |
|---|---|
find . -name '*.tmp' -delete |
通す |
git clean -n(何もしない) |
通す |
git clean -fd(本物の削除) |
通す |
誤検知が6件あることは、「厳しすぎる」ことを意味しない。
同じフックが、本物の削除を素通りさせている。
厳しさと正確さは別の軸で、文字列の一致で判定している限り、両方が同時に悪くなる。
だから対照の一覧には、止まってはいけないものと一緒に、
止まるべきなのに素通りしそうなものも入れておく。
消す前に、それが単独で止めているものを数える
誤検知が続くと、そのフックを外したくなる。実際に自分もそうしかけた。
消す前に測った。同じコマンドを9通りの呼び方——素のまま、絶対パス、相対パス、
sudo、env FOO=1、先頭の変数の代入、time、nohup、&& の後ろ——で撃って、
どのフックが止めたかを1本ずつ記録した。
結果は逆だった。誤検知を出していた雑な条件の方が、9通りのうち7通りを単独で止めていた。
文字列が在れば当たる作りなので、前に何が付いていても関係なく当たる。
一方、コマンドの位置をきちんと見ている側が止めたのは、素のままの形だけだった。
消していたら、7通りが守りから外れていた。
誤検知と見逃しは、別々の欠点ではなく同じ目盛りの両端にある。
毎日ぶつかるのは誤検知の側だけなので、体感は必ず「雑な方が悪い」に偏る。
見逃しは何も起きないので、体感に上がってこない。
だから消すかどうかは、体感ではなく、その1本が単独で止めている数で決める。
この測り方が答えていない問い
終了コードを読む測り方には、はっきりした限界がある。
これが答えるのは「届いたら止まるか」であって、「そもそも届くか」ではない。
私はここで一度やられている。2026年9月4日、同じコマンドを11通りの書き方で
Claude Code に実行させて、どれが止まるかを測った。
3通りは、Bash に届く前に書き方を整えられていた。
| 頼んだ形 | 実際にツールへ送られた文字列 |
|---|---|
(touch p) |
touch p |
"touch" q |
touch q |
cd /path && touch g |
touch g |
印のファイルは出来なかったので、素直に読めば「丸括弧も引用符も && も阻止された」になる。
だが阻止されたのは、私が書いた形ではない。括弧も cd も、フックは一度も見ていない。
自分で JSON を作って渡す測り方では、この差は原理的に見えない。
渡した文字列が、そのままフックに届くからだ。
実運用で本当に届く文字列を知りたいなら、送られた側の記録を読むしかない。
claude -p "…" --output-format stream-json --verbose
# 出てくる tool_use.input.command が、フックが実際に受け取る文字列
つまり測る対象は2つある。届いたときに正しく判定するか(この記事の8件で測れる)と、
そもそも届くか(送られた文字列を記録から取るしかない)。
前者だけを測って後者を測ったつもりになるのが、いちばん静かな間違いだ。
ここで扱ったのは「止まってはいけないものが止まる」側だが、
実際の運用では逆向き——入れたはずのフックが、肝心な時に何も止めていない——のほうが静かで怖い。
その両方の測り方と、800時間以上の自律運用で実際に踏んだ事故の一覧は
Anthropic公式ガイドにない事故防止——Claude Code 800+時間で19点→85点にした全記録(¥1,500)にまとめてある。
本編は第3章まで無料で読める(導入と早見表を含めて5章ぶん。ほかに最終章も無料)。