Claude Code——AIがコマンドを実行しながら開発を手伝うツール——には、危ない操作を実行する直前にスクリプトで中身を検査して止める「フック」という安全装置がある。rm -rf のような取り返しのつかない命令を、実行の手前で止めるためのものだ。
うちではその安全フックを自分の Claude Code(以下CC)に書かせて、無料で配っている。この記事は、配っていたそのフックが、ある条件下で検査を一つもせずに全部を通していたという話だ。自分のフックで同じことが起きていないかを、30秒で確かめられる形にしてある。
Claude Code のフックの例を検索すると、たいてい同じ3行から始まる。
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$CMD" ] && exit 0
うちのCCが書いたフックも、全部この型だった。無料で配っているフック集も、プラグインも。
この3行に穴があると言われた。jq は JSON から値を取り出す外部のコマンドで、Claude Code に同梱されているわけではない。入っていない環境では、危ない操作が全部そのまま通る。しかもエラーは1行も出ない。
信じたくなかったので、実際に走らせてもらった。プラグインとして配っている19本のうち17本がこの形で、遮断すべき操作を14通り試したところ、14通りとも素通りした。
なぜ素通りするのか
【CC補足】3行のうち、危ないのは3行目
2行目でjqが見つからないと、シェルは「コマンドが無い」というエラーを出す。だがその出力は2>/dev/nullで捨てられている。jqは何も標準出力へ書かないので、CMDは空文字になる。
3行目[ -z "$CMD" ] && exit 0は、本来「道具の入力にコマンドが含まれていなかった」という正常な場合のための行だ。ファイルを読むだけの呼び出しなど、検査対象が無い時に素直に通すための保険にあたる。
問題は、この行が**「検査する対象が無かった」と「検査する道具が無かった」を区別できない**こと。前者は通してよい。後者は絶対に通してはいけない。どちらも同じ空文字なので、区別のしようがない。
利用者から見える景色を書くと、こうなる。導入は成功と表示される。設定ファイルにもちゃんと登録される。起動してもエラーは出ない。危ない操作をしても止まらない。そして止まらなかったことは、危ない操作が無かった時と画面上でまったく同じに見える。
守られていると信じている人にとっては、守りが無いことより悪い。私はまさにそう信じていた側だ。
4つの環境で実測した
配っている4つのプラグイン、合わせて19本のフックを取り出して、読者の環境を4通り作って走らせた。遮断すべき入力は14通り。再帰削除、強制送信、履歴の巻き戻し、認証情報ファイルへの書き込み、パッケージの公開など。
| 読者の環境 | 遮断できた数 |
|---|---|
| jq がある | 14 / 14 |
| jq が無く python3 がある | 0 / 14 |
| jq も python3 も無く node がある | 0 / 14 |
| どれも無い | 0 / 14 |
素通りした42件のうち、警告を出したものは1件も無い。全部が静かに終了コード0を返した。
【CC補足】この環境は珍しくない
jqは macOS にも標準的な Linux にも最初から入っていない。Claude Code 自体もjqを要求しない。つまり「Claude Code は動くがjqは無い」構成はごく普通に存在する。
検査はPATHに置く道具を絞ってjq/python3/nodeの有無を切り替えるだけなので、誰でも同じ4通りを再現できる。終了コード2が遮断、0が素通り。
自分の settings.json でも30秒で確かめられる
まず、登録されているコマンドを見る。
grep -o 'jq[^"]*' ~/.claude/settings.json | head -20
ここに何か出たなら、次はそのフックが jq 無しでどう振る舞うかを実際に見る。
# jq を含まない PATH を作る(自分のフックが使う道具は足しておく)
mkdir -p /tmp/nojq
for t in cat grep echo sed bash sh awk cut tr wc; do ln -sf "$(command -v $t)" /tmp/nojq/$t; done
# 止まってほしい入力を、そのフックへ直接与える
echo '{"tool_input":{"command":"rm -rf /tmp/testdir"}}' \
| env PATH=/tmp/nojq bash ~/.claude/hooks/あなたのフック.sh
echo "終了コード=$?"
終了コードが2なら守られている。0で、しかも何も表示されないなら、そのフックは jq が無い環境で眠っている。
この検査を通したのは、配布物へ読者を案内する直前だった。案内してから気づいていたら、守られていないものを守られていると言って配ったことになる。順番が逆でなくて助かった、というのが正直なところだ。
直し方は「代わりを探す」と「黙らない」の2つ
【CC補足】読み取りの道具を1つに賭けない
jqが無ければpython3、それも無ければnodeを使う。Claude Code を npm で入れた環境ならnodeは必ず存在する。
そのうえで肝心なのは最後の分岐で、3つとも無い時に黙って通してはいけない。
INPUT=$(cat)
CMD=$(printf %s "$INPUT" | {
if command -v jq >/dev/null 2>&1; then
jq -r '.tool_input.command // empty' 2>/dev/null
elif command -v python3 >/dev/null 2>&1; then
python3 -c 'import sys,json;print((json.load(sys.stdin).get("tool_input") or {}).get("command") or "")' 2>/dev/null
elif command -v node >/dev/null 2>&1; then
node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String((JSON.parse(s).tool_input||{}).command||""))}catch(e){}})' 2>/dev/null
else
echo __NO_PARSER__
fi
})
if [ "$CMD" = "__NO_PARSER__" ]; then
echo "警告: このフックは入力を読めないので、あなたを守っていない" >&2
exit 0
fi
[ -z "$CMD" ] && exit 0
ここで遮断を選ばなかったのには理由がある。読めない時に全部止めると、その環境ではすべての道具の呼び出しが止まって Claude Code が使えなくなる。安全のために仕事を全部止めるのは、安全ではなく故障だ。だから通す。ただし声は出す。警告は毎回だと騒がしいので、印のファイルを置いてセッションに1回だけにした。
直した後で同じ4環境をもう一度走らせたら、python3 だけの環境も node だけの環境も14/14で遮断するようになった。3つとも無い環境は遮断こそしないが、14件すべてで「守っていない」と表示が出る。
同じ日に、同じ形を別の道具でも踏んでいた
jq を直した後、「他の道具でも同じことが起きていないか」を調べさせた。起きていた。
【CC補足】
grep -Pも同じ構造
grep -P(Perl互換の正規表現)は GNU の拡張で、macOS の BSD grep は受け付けない。-oPで値を取り出している行が macOS では失敗して空を返し、2>/dev/nullが失敗の出力を消すので、呼び出し側は「何も一致しなかった」と読む。jqの時と構造が一字も違わない。
直す時の注意点が1つ。\K(一致の開始位置をずらす記法)には POSIX の等価が無いため、素直に書き換えると Linux 側の挙動まで変わる。-Pが使える時は既存の経路をそのまま通し、使えない時だけ-Eとsedの等価な経路へ落とした。
見つけ方が面白かった。新しい検査は1本も書いていない。手元にある試験を、道具が欠けた環境でもう一度走らせただけだ。-P を拒む偽の grep を PATH の先頭に置いて、234本の試験を両方の環境で回す。手元で緑なのにそちらで赤になる試験が、そのまま該当箇所にあたる。
結果は234本のうち6本。認証情報ファイルのアップロードの遮断、システムのディレクトリの再帰削除の遮断、移動の後に親を消す並びの遮断、環境ファイルの削除の遮断が20件。全部、止めるべき側で止まらなくなっていた。
直している最中に、CCは自分で退行も入れた。共通の関数を rm の処理の内側で定義したのに、find の処理は外側からそれを呼んでいて、find -delete の許可3件が遮断に変わった。試験が捕まえたので出荷せずに済んでいる。
持ち帰ってほしいこと
外部のコマンドに頼って判断する検査には、この形の穴が入り込みやすい。今回は jq と grep -P の2つで踏んだが、道具の名前は何でもいい。判断の材料が取れなかった時に、安全側ではなく素通り側へ倒れる書き方になっていないか。そこだけ見てほしい。
見分け方は簡単だ。「材料が無かった」と「材料を取る道具が無かった」を、そのコードが区別しているか。区別していなければ、道具が消えた日に検査は消える。消えたことは誰にも見えない。
うちはフックを909本(2026年8月2日時点)公開しておきながら、配っている30本のうち25本が特定の環境で全部眠ることに、実際に走らせるまで気づかなかった。形の検証は通っていた。JSON としても正しく、登録先も合っていた。それでも動かなかった。
追記——直したつもりが、いちばん多くの人が通る道に届いていなかった
この記事の内容は、朝のうちに直して出荷した。それで終わったと思っていた。
夕方、まったく別の用事で同じ製品の別のファイルを開いたら、そこにも同じ3行の型が残っていた。中核の導入処理が配るスクリプト、つまり読者がいちばん最初に打つコマンドで実際に置かれるほうだ。11本のうち8本。
朝に直したのはプラグインとして配っている19本のほうで、中核の側は1本も直っていなかった。
| 入力 | jqあり | jqなし |
|---|---|---|
| システムのディレクトリへの再帰削除 | 遮断(終了コード2) | 素通り(終了コード0)・出力なし |
| 作業ツリーを捨てる巻き戻し | 遮断(終了コード2) | 素通り(終了コード0)・出力なし |
同じ日に、同じ欠陥を、同じ手で、2つの配布経路のうち片方だけ直していた。しかも直していなかったほうが、読者の多い道だった。
「直した」と「届いた」は別のことだ。どこに配っているかを数えて、そのそれぞれで現物を走らせるまで、直したことにはならない。今回は隔離した環境に読者と同じコマンドで入れ直し、置かれたファイルを1本ずつ数えて確かめている。
もう一つ正直に書いておく——「例」として置いてある909本は、まだこの型のままだ
直したのは、実際に読者の環境で動く30本だ。プラグインとして配っている19本と、中核の導入が置く11本。
一方で、うちのリポジトリには参考用の「例」として909本のフックが並んでいる。公開している本流をいま落として1本ずつ数え直したら、798本が jq で読み取っていて、そのうち772本は jq があるかどうかを確かめないまま使っていた。この記事で書いた3行の型が、そのまま残っている。
【CC補足】772本の全部が危ないわけではない
中を開くと、操作の履歴を書き出すもの、費用を集計するものといった、記録を取るだけのフックがかなり混ざっている。読み取りに失敗して黙って終わっても、失われるのは記録であって守りではない。危ないのは遮断を担うほうで、そちらはjqが消えた日に、上の表とまったく同じ形で眠る。
だから「909本すべてを直した」とは書けない。いま言えるのは、--install-exampleで例のフックを入れている人は、上の30秒の検査を自分の手で1回やってほしい、ということだけだ。
数を書いておくのは、直した範囲を実際より広く読ませたくないからだ。30本を直した話を読んで909本が安全になったと受け取られると、この記事は「守られていると信じている人」を1人増やすことになる。それはこの記事がいちばん減らしたかったものだ。
上で直したフックは、Claude Code の中から2つのコマンドで入る(無料・MIT)。
/plugin marketplace add yurukusa/cc-safe-setup
/plugin install safety-essentials@cc-safe-setup
再帰削除、強制送信、履歴の巻き戻し、環境ファイルへの書き込み、パッケージの公開を実行の前で止める。合わなければ /plugin uninstall safety-essentials@cc-safe-setup で外せる。
追記で書いた中核の側の是正も同じ場所に入っている。こちらは npx github:yurukusa/cc-safe-setup で入る。
自律運用で実際に起きた事故と、その予防の手順は Claude Code 事故防止の本(第3章まで無料) にまとめてある。