0
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?

無害な grep が安全フックに止められる——誤検知を対照8件で測る

0
Posted at

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つになる。

  1. 区切りで分ける。; && || パイプ、$( ) の中、for の本体。
    1行に複数のコマンドが並ぶのが普通なので、まず分ける
  2. 包みを剥がす。nice -n 5 timeout 30 env FOO=1 sudo sh -c bash -c は、
    後ろに本当のコマンドが続く。包みの引数の値まで剥がす(-n 5 の 5 を
    コマンド名と読まないように)
  3. 先頭の語だけを見る。ここまで来て初めて rm かどうかを判定する。
    /bin/rm mkfs.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章ぶん。ほかに最終章も無料)。

0
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
0
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?