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?

フックが正しい瞬間に走っているかを測る手順——909本中24本は別の瞬間に走る

0
Posted at

自分が配っている Claude Code のフックを909本(2026年8月4日の時点)、機械で検査した。目的は単純で、「これ全部ちゃんと動くのか」を確かめたかった。

エラーは1件も出なかった。構文は通る。登録先の名前も全部実在する。診断コマンドも黙っている。

それでも24本が壊れていた。

先に前提を書いておく。私(ゆるくさ)は非エンジニアで、Claude Code——AIがコマンドを実行しながら開発を手伝うツール——に、ほぼ自律で作業させている。フックというのは、そのAIが何かを実行する直前・直後に割り込んで検査する仕組みのことだ。rm -rf を止める、.env のコミットを止める、といった使い方をする。私は事故が怖いので、これを何百本も入れて配ってもいる。

困っていたのは、数が増えすぎて「本当に効いているのか自分で分からなくなった」ことだ。動いているように見える。エラーも出ない。でもそれは効いている証拠ではない。この記事は、外に正解が無い状態でどうやって間違いを見つけたかの記録と、同じ検査を自分の環境で走らせる手順だ。

壊れ方が厄介だった。どの検査にも引っかからないのに、意図した瞬間には呼ばれない。lint が編集の前に走り、セッションの締めくくりの処理が毎回のコマンドごとに走る。動いてはいる。ただ、間違った時に。

以下は2026年8月3日、Claude Code 2.1.220 で手を動かした記録だ。同じ検査は誰の環境でもできる。


まず「動く」と「正しい」がずれる話から

Claude Code のフックは、設定ファイルにこう書いて登録する。

{
  "hooks": {
    "PostToolUse": [
      { "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/lint.sh" }] }
    ]
  }
}

"PostToolUse" の部分がいつ呼ばれるかを決める。この例なら「ファイルを編集した後」。lint を走らせるならここでいい。

ここを "PreToolUse"(編集する前)に書き換えても、設定は通る。エラーも警告も出ない。lint は毎回きちんと走る。ただし編集される前のファイルに対して走る。つまり、直したはずの内容を永遠に見ない。

これが「動くのに間違っている」の正体だ。名前が存在しないなら機械が教えてくれる。存在するが場所が違う場合は、誰も教えてくれない。

名前そのものが実在しない場合は claude doctorUnknown hook event "..." was ignored と教えてくれる。ただし「無視された」だけで、エラーにはならない。まずこれを確認しておくと、今回の話とは別の種類の事故を1つ潰せる。

検査の作り方——ファイルを、ファイル自身と突き合わせる

外に正解が無いなら、どうやって間違いを見つけるか。

自分の場合は運が良かった。それぞれのフックが、登録先を2箇所に書いていた。

  1. ファイル上部の、コピーして使ってもらうための設定例
  2. 下部の # TRIGGER: という1行メモ

同じことを2回書いてあるなら、食い違っていたらどちらかが間違っている。正解を外から持ってこなくても、矛盾は見つけられる。

# 各ファイルから、2箇所の申告を抜き出して照合する
trigger = set(re.findall(r'[A-Z][a-zA-Z]+', trigger_line)) & VALID_EVENTS
snippet = set(re.findall(r'"([A-Za-z]+)"\s*:\s*\[', text)) & VALID_EVENTS
if trigger and snippet and not (trigger & snippet):
    print(f"{filename}: 申告が食い違っている")

VALID_EVENTS には実在するイベント名だけを入れておく(散文に混じった普通の英単語を拾わないため)。909本に当てて、24本が出た。

数が多かったので、まず自分の検査を疑った

24本という数字が出たとき、最初にやったのは是正ではなく検出式の検証だ。「24本も壊れているはずがない、たぶん自分の正規表現がおかしい」と思った。

3本を選んで実物を開いた。

# test-after-edit.sh
  設定例  : "PostToolUse"
  TRIGGER : PreToolUse  MATCHER: "Bash"

# session-summary-stop.sh
  設定例  : "Stop"
  TRIGGER : PreToolUse  MATCHER: "Bash"

# notify-waiting.sh
  設定例  : "Notification"
  TRIGGER : PreToolUse  MATCHER: "Bash"

検出は正しかった。3本とも # TRIGGER: PreToolUse MATCHER: "Bash" で、まったく同じ文字列。テンプレートを複製したときの初期値が、そのまま残っていた。

疑って開いてよかった。もし検出式が壊れていたなら、24本を「直す」作業は24本を「壊す」作業になっていた。

どちらが正しいかは、中身が知っている

食い違いを見つけても、どちらを直すかは別の問題だ。片方に揃えれば矛盾は消えるが、間違った側に揃えたら意味がない。

判定の材料は3つ使った。

その道具が何をするか。test-after-edit.sh は「編集したファイルのテストを促す」。編集の後でなければ成り立たない。session-summary-stop.sh は「セッションの変更点をまとめて表示する」。毎回のコマンドで出したら邪魔なだけだ。この時点でほとんどが決まる。

中身が何を出力しているか。判断に迷ったのは自動承認の3本だった。# TRIGGER: には PermissionRequest と書いてある。設定例は PreToolUse。どちらもあり得る。

実装を開いたら決着した。

jq -n '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'

3本とも hookEventNamePreToolUse と書いて出力していた。中身が自分で名乗っている。設定例が正しい。

公式の説明。ついでに、この3本が特に危ない理由も分かった。リファレンスによると PreToolUse は "Before a tool call executes"、PermissionRequest は "When a tool call needs a permission decision" とある。後者は確認を求める必要があるときだけ呼ばれる。

つまり確認を飛ばすモードで走らせていると、PermissionRequest のフックは一度も呼ばれない。設定してあるのに、いちばん自動で走っている時間帯だけ無防備になる。これは名前が実在するせいで、診断コマンドでも気づけない。

直すときは、直した以外が変わっていないことを確かめる

23本(1本は別途対応済み)の1行だけを書き換える作業だ。単純に見えるが、機械置換は静かに他の行を壊す。

安全弁を1つ入れた。

before = [l for l in old.split('\n') if not l.startswith('# TRIGGER:')]
after  = [l for l in new.split('\n') if not l.startswith('# TRIGGER:')]
assert before == after   # TRIGGER行以外が1文字でも変わったら止まる

23本すべてで通過。差分も追加23行・削除23行ちょうどで、狙った行以外は動いていない。

置換が終わったら、最初の検査をもう一度全部に当てる。食い違い 0本。是正の前は24本だったので、検査の側も生きている。

——と、この時は書いた。9日後に同じ検査を当て直したら、1本残っていた。

auto-approve-python.sh だ。上の「1本は別途対応済み」がこれで、別途の対応が実際には入っていなかった。TRIGGER 行は PermissionRequest のままで、同じファイルの設定例も使い方の行も、フック自身の出力の4箇所も PreToolUse と名乗っている。2026年8月12日に1行だけ直して、いまは 0本だ。

つまり、この記事が書いている「最終の検査で0本」は、当時の私の思い込みだった。一斉に直したあとの確認は、直した対象だけでなく、対象から外した1本にも当てる。外した理由が「別途対応する」なら、それは確認ではなく予定だ。

持ち帰れること

自分のフックが1本でもあるなら、確認は2段階でいい。

1段目。名前そのものが存在するか。

claude doctor

Unknown hook event が出たら、そのフックは一度も動いていない。

2段目。存在する名前に、正しく置いてあるか。これは機械には判定できないので、フック1本ずつ「これは何をする道具で、いつ呼ばれるべきか」を声に出して言うのが早い。

  • ファイルを検査する道具 → 編集の後(PostToolUse
  • 危険な操作を止める道具 → 実行の前(PreToolUse)。確認を飛ばすモードでも呼ばれるのはこちら
  • 締めくくりの処理 → ターンの終わり(Stop)。ただし Stop は毎ターン呼ばれる。セッションの終わりは SessionEnd

そして、いちばん言いたいのはこれだ。エラーが出ないことは、正しいことの証明にならない。 名前を間違えれば機械が教えてくれる。場所を間違えたときは、何も起きない。

自分の場合、その「何も起きない」が24本ぶん積み上がっていた。見つかったのは、それぞれのファイルが同じことを2回書いていたからで、要するに偶然だ。もし1箇所にしか書いていなかったら、今も気づいていない。

追記(2026年8月12日)。この記事を書いたあとで、同じ「何も起きない」の3つ目の型に当たった。今度は検査する道具のほうだった。

自分は「入れたフックが、いま配っている版と一致するか」を照合する命令を書いて配っている。まさにこの記事の教訓から作ったものだ。ところがその命令は、既定で入る中核のフックを一本も見られなかった。中核だけは別の場所に持っていて、照合の対象がそこを含んでいなかった。だから中核が2か月半古いままの環境に対して、その命令は「この企画が配っていないフックなので未確認」と返していた。

実害も測った。入れた版と配る版へ同じ入力を渡すと、配る版だけが止める危険な書き方が6つあった。同じコマンドを単独の形で渡すと両方とも止まるので、測り方の誤りではない。自分のログでは git push を含む呼び出し515回のうち502回(97.5%)が古いフックの視界の外にあり、全文を読み直すと本物の強制 push が2回、止められずに実行されていた。

名前を間違えれば機械が教えてくれる。場所を間違えたときは何も起きない。そして、その「何も起きない」を見つけるために作った機械も、見ている範囲を間違えることがある。「検知の仕組みは在る」は、安心してよい理由にならない。その仕組みが見ている集合と、守りたい集合を並べて数えるまでは。

追記(2026年8月13日)。4つ目の型に当たった。今度は逆向きだった。

この記事はずっと「間違っているのに何も起きない」を書いてきた。今回は反対で、正しいのに「壊れている」と言われた。言ったのは、自分が配っている診断の命令だ。

きっかけは、読者からの指摘だった。「複数の Claude のプロファイルを使い分けていると、.claude の決め打ちで壊れる」。導入する側はその指摘どおりに直してあった。環境変数が指す場所があればそこへ、無ければ従来どおり利用者のホームへ書く。

診断する側が、その修正に追随していなかった。

診断はホームだけを見ていた。だからプロファイルごとに入れている人の環境では、設定ファイルを1つも見つけられず、こう言っていた。

設定ファイルがありません。フックは動きません。入れ直してください。

フックは8本、正しく登録されて動いていた。

読んで気づいたのではない。隔離した環境で、実際に走らせて分かった。環境変数を設定して導入すると、設定ファイルはプロジェクト側にだけ書かれ、ホーム側には存在しない。その状態で診断を走らせたら、上の行が出た。

直し方は単純で、導入する側が書いた場所を、同じ順で見るだけだった。直したあとは両方向で確かめた。プロファイルごとの環境でも、従来のホーム導入でも、どちらも8本を正しく数える。

ここで書いておきたいのは、直し方より、見つけ方のほうだ。

読者の指摘は、1行のコードを名指ししていた。その1行は直っていた。私が確かめたのは「その1行だけ直して、同じ間違いを他に残していないか」で、残っていた。指摘は具体的であるほど、その具体的な1点で満足してしまう。

もう1つ。配っているファイルのうち、設定ファイルに言及するものは53件あった。そのうち実行される行でホームを決め打ちしていたのは6件で、残り40件は注記の中の言及だった。6件のうち2件は相対パスも見ているので実害が出にくい。明確な欠陥は1件だった。検査が当てた数(53)は、直すべき数(1)ではない。ここを混ぜると、直しに行った先で正しいものを壊す。

そして、この記事の主題との関係。

「間違っているのに何も起きない」と「正しいのに壊れていると言われる」は、症状としては正反対だ。だが根は同じところにある。導入する側だけを読んでも正しい。診断する側だけを読んでも正しい。2つを並べたときにだけ、真実の在り処について合意していないことが分かる。

1つのファイルをどれだけ丁寧に読んでも、この形の間違いは出てこない。出てくるのは、並べたときだけだ。

この型を自分の環境で見つけたい人へ

ここまでの4つは、どれも「単体では通るのに、突き合わせると食い違う」という同じ形をしている。ただし食い違う場所が違う。最初の24本は1つのファイルの内側(設定例と TRIGGER 行)で食い違っていた。3つ目は違って、照合の道具と、道具が見ていなかった置き場所という、ファイルとファイルの間で食い違っていた。4つ目も間で、しかも向きが逆だった。導入する側と診断する側が、設定の在り処について合意していなかった。

内側の食い違いは、今回のように運が良ければ自分で見つけられる。間の食い違いは難しい。どのファイルを開いても、そのファイルは正しいからだ。

同じ点検を自分の環境へ当てるための道具は、無料で公開している。

自分で回すのが面倒な人向けに、同じ点検を書面で請け負う仕組みも作った(層と層のあいだの矛盾を読む監査・¥29,800・72時間以内)。CLAUDE.md と settings.json とフックとセッションのログと CI を、互いに突き合わせて、層と層の間に落ちている矛盾だけを報告するものだ。

ただし正直に書いておく。単体のファイルの点検で済む人には要らない。 設定が1枚、フックが数本、という環境なら、上の無料の道具を回すほうが速いし安い。これが効くのは、層が増えて「どれも正しいのに全体としては間違っている」が起きはじめた環境だけだ。実際、この記事の24本も、私が層を増やしすぎた結果として出たものだった。


この記事の調査・検査・是正は Claude Code に実行させた。本文の数値は手元の環境(WSL2 / Claude Code 2.1.220 / 当時909本のフック)での2026年8月4日の実測。追記の数値はそれぞれ追記の日の実測で、8月12日ぶんは同日、8月13日ぶん(診断が設定ファイルを見つけられなかった件・53件中6件・明確な欠陥1件)は同日に、隔離した環境で走らせて取った。公式ドキュメントからの引用は原文のまま。是正の内容は MIT ライセンスで公開している。

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?