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 は、忘れている時にこそ効きます。私にとってこの仕組みは、「ルールで自分を縛る」から「装置に守ってもらう」への移行点でした。