Claude Code を個人プロジェクトで毎日触っていると、git push は 1 日に何度も走る。そのたびに、「秘密情報が混ざっていないか」「別タブで積まれた commit を巻き込んでいないか」といった点を毎回確認したいのだが、手動でやると抜ける。自分自身も、Claude に任せても、確率的に忘れる。

そこで、この確認を hook に代行させる仕組みを作っている。以下はその実装と、運用して得た手応えの話だ。

何を守りたいのか

git push の前に、次の 2 つを毎回通したい、というのが出発点だった。

  1. 秘密情報チェック: API キー・トークン・メールアドレス・.env の中身がコミットに混ざっていないか
  2. commit 範囲チェック: origin/main..HEAD に、自分の担当外の commit(別タブで積まれた commit)が混ざっていないか

1 は単純な secret スキャンでも潰せる。ただ、機械スキャンでは微妙な怪しさ(「秘密ではないがプライベートな情報」)までは拾いきれない。2 は、タブを並列で動かしている以上、自分の commit 以外が HEAD に積まれている可能性が常にある。

これらを毎回、Claude のサブエージェント(security-reviewer)に依頼して差分を読ませてから push する、という運用にしている。

ルールだけでは守れない

素直に考えると、CLAUDE.md や .claude/rules/ に「push 前に security-reviewer を呼べ」と書くだけで終わりそうだ。実際、そうしていた時期もあった。

ただ、人間も AI も忘れる。作業に集中していると、push する瞬間にレビュー工程が頭から抜ける。Claude も、長いタスクの流れの中で、ルールを 100% 守るとは限らない。ルールは、読み手が読んでいる時にだけ効く仕組みだ。

ルールに書くだけだと「気をつける」になる。hook に落とすと「機械的に防ぐ」になる。この違いは、実運用では大きい。

PreToolUse hook でゲートを張る

Claude Code には PreToolUse hook という仕組みがある。ツールが実行される直前にスクリプトを走らせて、許可・拒否を決められる。

これを使って、Bash ツールで git push が走ろうとした瞬間に「security-reviewer 経由のレビューが終わっているか」を確認し、終わっていなければ push を弾く、という仕組みを作った。

hook の登録は .claude/settings.jsonhooks.PreToolUse で行う。マッチャに Bash を指定して、実行前にガード用スクリプトを呼ぶ、という最小の設定だ。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 .claude/hooks/git-push-guard.py"
          }
        ]
      }
    ]
  }
}

スクリプト側は $CLAUDE_TOOL_INPUT(実行予定のコマンド)を読み、git push を含む時だけガード処理に入る。git push でなければ何もせず通す、というゲート方式だ。

「レビューが終わっている」ことを表すために、logs/security-review/マーカーファイル を置いている。security-reviewer がレビューを完了すると、対応する commit 範囲の hash を名前に含むマーカーを生成する。hook は、そのマーカーが存在するかを確認するだけだ。

マーカーの設計で悩んだこと

ここで少し踏み込んだ話をすると、「commit 範囲の hash」の取り方にひと工夫が必要だった。

単純に「最新 commit の sha」をマーカー名にすると、次のケースで抜ける。

  1. 自分が commit A を作り、レビュー → marker(A) 作成
  2. 別タブが commit B を main に積む(自分の HEAD = A + B)
  3. 自分が push しようとする
  4. marker(A) は存在するので hook は素通し → 未レビューの B が push される

これを防ぐために、マーカー名は sha(origin/main..HEAD の commit 列全体)range hash にしている。別タブが新しい commit を積んだ瞬間に range が変わり、マーカーが自動失効する。

range hash の計算自体は短いシェルで書ける。

RANGE_HASH=$(git log --pretty=format:%H origin/main..HEAD | sha256sum | cut -c1-16)
MARKER="logs/security-review/reviewed-${RANGE_HASH}.txt"

security-reviewer はレビュー完了時にこのマーカーを作成し、hook は push 直前に同名マーカーの存在を確認してから git push を通す。range が変われば、ここで名前が食い違って自動的に再レビューになる、という動き方だ。

加えて、マーカーは single-use(1 回の push で消費される) + TTL 30 分(放置マーカーの自動クリーンアップ目的)にしている。push が通るたびにマーカーは消え、時間が経てば自動で掃除される。

この 3 つ(range hash / single-use / TTL)を組み合わせることで、「自分が最後にレビューしたのと同じ commit 列を、1 回だけ push できる」という状態になる。

副次効果

この仕組みを入れてから、副次的に別の効果もあった。

1 つは、「別タブが積んだ commit が混ざっていないか」を毎回目視できることだ。security-reviewer は diff を読むので、レビュー結果として「この range には担当外の commit が混ざっています」と教えてくれる。複数タブで並列に作業している時、これで何度か事故を防いでいる。

もう 1 つは、push の粒度が整うことだ。レビューを通すコストが一定あるので、「とりあえず push」が自然に減り、意味のあるまとまりごとに push するようになった。

何が変わったか

この仕組みを入れる前は、push のたびに「本当に大丈夫かな」という小さな不安があった。入れた後は、その不安が hook に移譲された。

自分は忘れていい。hook が忘れない。そういう分担ができると、手元の作業が少し軽くなる。

ルールは、読む人が覚えていなければ効かない。hook は、忘れている時にこそ効く。自分にとってこの仕組みは、「ルールで自分を縛る」から「装置に守ってもらう」への移行点だった。