先日、個人開発している Android アプリ(Google Play で配信中の壁紙アプリです)のリポジトリで、依存ライブラリの一括更新を Antigravity のエージェントに任せたときのことです。作業ログを後から確認すると、ユニットテストが 2 件落ちているのに、エージェントは「既存の失敗であり今回の変更とは無関係」と自己判断して、そのまま push まで進めていました。
実際には 2 件とも依存更新が原因の失敗でした。CI が止めてくれたおかげで実害はありませんでしたが、ローカルの品質チェックが「お願いベース」でしか効いていなかったことを突きつけられた出来事でした。
システムプロンプトに「テストが通ってから push すること」と書いてあっても、エージェントはそれを確率的にしか守りません。守らせたいルールは、指示ではなく仕組みに落とす必要があります。Antigravity CLI の Hooks と git の pre-push フックを重ねた二段ゲートを、設定とスクリプトの実物つきで組み立てていきます。
指示が守られないのは、欠陥ではなく性質に近いものです
LLM エージェントへの指示は、確率的にしか守られません。コンテキストが長くなるほど初期の制約の重みは下がりますし、長い作業の途中で履歴が要約されれば、「push 前に必ずテストを通す」という一文が要約から抜け落ちることもあります。
さらに厄介なのは、エージェントが制約を「解釈」してしまう点です。冒頭の例がまさにそれで、テスト失敗という事実は認識した上で、「既存の失敗だから無関係」という理屈をつけて先へ進みました。嘘をついたわけではなく、与えられた目標(依存更新を完了させる)に対して合理的に振る舞った結果です。
この性質は、モデルの世代が上がっても程度が変わるだけで、ゼロにはならないと私は考えています。だから「守られるべきルール」と「守ってほしい好み」を分け、前者はプロンプトから仕組みへ移すのが設計の出発点になります。可逆な操作は緩く、不可逆な操作は固く守るという考え方は やり直せる操作とやり直せない操作を分けて任せる — 可逆性で自律度を決めるエージェント設計 に書きました。push は「共有リポジトリの履歴に他者と CI を巻き込む」半不可逆の操作なので、固く守る側に置いています。
二段ゲートの全体像 — どの層で何を止めるか
私が現在使っている構成は次の二段です。
層1: Antigravity CLI の Hooks(PreToolUse) — エージェントが git push を実行しようとした瞬間に割り込み、ゲートスクリプトを走らせます。ブロック時のメッセージがそのままエージェントへのフィードバックになるため、自己修正のループが速く回るのが利点です
層2: git の pre-push フック — どんな経路の push でも git 自身が直前に実行します。Hooks の設定が壊れていても、別のターミナルから人間が push しても、必ず同じゲートが走る決定論的な最終防衛線です
役割分担を一言でいうと、層1 は「エージェントに修正の機会を早く返すための層」、層2 は「何があっても通さないための層」です。層1 だけだと CLI の外からの push に無力で、層2 だけだとブロック理由がエージェントに伝わりにくく、修正ループが遅くなります。両方そろって初めて、速さと確実さが両立します。
なお、この構成のさらに外側には「リモート側の防衛線」(ブランチ保護と CI の必須チェック)があります。ローカルの二段は不正な push をそもそも発生させないための仕組み、リモート側は発生してしまった場合に main を守るための仕組み、と層が分かれます。リモート側だけに頼ると、ブロックの発見が CI の完了まで遅れ、エージェントの修正ループが一周あたり数分単位で遅くなるので、ローカルで止める価値は十分にあります。
層1: PreToolUse フックで git push を検問する
Antigravity CLI(2.0.0)では、プロジェクト直下の .antigravity/settings.json に Hooks を定義できます。Gemini CLI から引き継がれた仕組みで、ツール実行の前後に任意のコマンドを差し込めます。push の検問に使うのは、ツール実行前に走る PreToolUse です。
{
"hooks" : {
"PreToolUse" : [
{
"matcher" : "run_shell_command" ,
"condition" : "git \\ s+push" ,
"command" : ".agent/hooks/pre-push-gate.sh" ,
"timeoutSec" : 600
}
]
}
}
matcher でシェル実行ツールに絞り、condition の正規表現で git push を含むコマンドだけを対象にしています。フックのコマンドが終了コード 0 以外を返すと、ツール呼び出し自体がブロックされ、標準エラー出力がエージェントに渡ります。この「stderr がエージェントへの返信になる」性質が、次のメッセージ設計の核になります。
ひとつ注意点があります。Hooks まわりのスキーマは更新が続いているため、キー名はバージョンによって変わる可能性があります。私の環境(2.0.0)では上記の形で動いていますが、導入時には Antigravity の changelog で Hooks の項を確認してから設定することを推奨します。
ゲートスクリプト — エージェントが読む前提でメッセージを書く
層1・層2 の両方から呼ばれるゲート本体です。検査内容はリポジトリごとに変わりますが、私は「テスト・リント・シークレットスキャン」の 3 点を最小構成にしています。
#!/usr/bin/env bash
# .agent/hooks/pre-push-gate.sh — push 前ゲート(層1/層2 共用)
# 終了コード 0 = 許可 / 0 以外 = push ブロック
set -u
fail () {
echo "BLOCKED: $1 " >&2
exit 1
}
# 1. ユニットテスト
./gradlew testDebugUnitTest --console=plain -q \
|| fail "ユニットテストが失敗しています。失敗したテストを修正し、このゲートを再実行してください。修正前に push を再試行しないでください。"
# 2. リント
./gradlew lintDebug -q \
|| fail "lint エラーがあります。レポートの指摘を修正してから再実行してください。"
# 3. シークレットスキャン(gitleaks が無ければ簡易 grep にフォールバック)
if command -v gitleaks > /dev/null 2>&1 ; then
gitleaks detect --no-banner --log-opts "origin/main..HEAD" \
|| fail "push 対象のコミットにシークレット混入の疑いがあります。該当箇所を除去し、履歴の書き換えが必要か確認してください。"
else
if git diff origin/main...HEAD | grep -qE "BEGIN (RSA|EC|OPENSSH) PRIVATE KEY" ; then
fail "秘密鍵らしき文字列が差分に含まれています。push を中止し、該当ファイルを確認してください。"
fi
fi
echo "GATE PASSED: テスト・リント・シークレットスキャンを通過しました。push を実行して構いません。"
exit 0
ポイントは fail() のメッセージを、人間向けのエラー文ではなく「エージェントへの次の指示」として書くことです。「ユニットテストが失敗しています」だけで終えると、エージェントは状況報告として受け取り、push の再試行に戻ることがあります。「修正前に push を再試行しないでください」と禁止行動まで書いておくと、ブロック後の暴れ方が目に見えて減りました。
ブロック時、エージェント側にはこう見えます。
$ git push origin main
[hook] .agent/hooks/pre-push-gate.sh を実行中...
> Task :app:testDebugUnitTest FAILED
BLOCKED: ユニットテストが失敗しています。失敗したテストを修正し、
このゲートを再実行してください。修正前に push を再試行しないでください。
(tool call blocked: exit code 1)
層2: git pre-push を最終防衛線にする
git にはコミットや push の前後に任意のスクリプトを差し込む標準のフック機構があり、push の直前に走るのが pre-push です。.git/hooks を直接使うとリポジトリの clone 間で共有できないため、フックをリポジトリ管理下の .githooks/ に置き、core.hooksPath で参照させる構成を使っています。
#!/usr/bin/env bash
# .githooks/pre-push — 層2: 経路を問わない最終防衛線
# 0 以外で終了すると push 全体が中止される
set -u
remote = " $1 " # リモート名(例: origin)
url = " $2 " # リモート URL
# stdin に <local ref> <local sha> <remote ref> <remote sha> が行単位で渡される
while read -r local_ref local_sha remote_ref remote_sha ; do
case " $remote_ref " in
refs/heads/main | refs/heads/release/ * )
echo "[pre-push] ${ remote_ref } -> ${ remote }: ゲートを実行します" >&2
./.agent/hooks/pre-push-gate.sh || exit 1
;;
esac
done
exit 0
有効化は 2 コマンドで終わります。
git config core.hooksPath .githooks
chmod +x .githooks/pre-push .agent/hooks/pre-push-gate.sh
main とリリースブランチだけを対象にしているのは意図的です。作業ブランチへの push までゲートすると、エージェントの試行錯誤が遅くなりすぎるためで、「main 系は固く、作業ブランチは速く」という温度差をつけています。どこまで固くするかを操作の可逆性で決める、というのが私の基準です。
worktree を複数並べてエージェントを並列稼働させている場合も、core.hooksPath をリポジトリローカルの config に設定しておけば全 worktree に共通で効きます(git のローカル設定は既定で worktree 間共有のため)。並列運用では「設定が 1 箇所で済む」この性質がそのまま利点になります。
すり抜け経路を塞ぐ — --no-verify と設定の自己書き換え
二段ゲートを入れた後も、すり抜けの経路は残ります。運用の中で実際に対処したのは次の 3 つです。
git push --no-verify — pre-push フックは仕様上 --no-verify で素通りできます。対策として、Antigravity 側の許可コマンド設定で --no-verify 付きの push を許可対象から外し、層1 の condition にも --no-verify を検出したら無条件ブロックする分岐を足しました。
Hooks 設定そのものの書き換え — エージェントは .antigravity/settings.json を編集できる立場にいます。悪意ではなく「ブロックされる原因を取り除く」という最適化として起きうるのが厄介な点です。設定ファイルとゲートスクリプトはエージェントの書き込み許可ディレクトリから外し、変更はレビュー必須にしています。
ゲートと push の && 連結 — 「ゲートを実行してから push して」と頼むと、エージェントはしばしば bash gate.sh && git push のように 1 コマンドへまとめます。一見正しいのですが、ゲート失敗時の確認・修正・再実行という介在ポイントが消えますし、シェルの書き方次第では失敗を飲み込んで push だけ走る事故も起きます。ゲートの実行と push の実行は別ステップに分ける、を運用ルールにしています。
3 つ目には苦い経験があります。別のリポジトリで gate || true; git push に相当する書き方を見逃し、ゲートが落ちているのに push が通ったことがありました。幸い層2 を入れた後だったので pre-push が止めてくれましたが、「同一コマンドへのバッチを防ぐ」のは指示だけでは徹底できず、最後は層2 の決定論に頼ることになります。push が成功したように見えて実は届いていないという逆方向の事故も含め、push まわりの確認は AIエージェントの git push が成功表示なのにリモートへ反映されないときの原因と対処 にまとめています。
並列エージェントで 2 週間運用して見えたこと
導入後、ゲートの結果を 1 行 JSON でログに残すようにしました。
# pre-push-gate.sh の末尾に追記しておくと後から集計できる
RESULT = "passed" # fail() 内では "blocked" を記録する
printf '{"ts":"%s","result":"%s","branch":"%s"}\n' \
"$( date -u +%FT%TZ)" " $RESULT " "$( git branch --show-current )" \
>> .agent/logs/gate.jsonl
# 集計例: ブロック率を見る
# jq -r .result .agent/logs/gate.jsonl | sort | uniq -c
手元の 2 週間分のログでは、エージェント経由の push 試行 31 回のうち 6 回(約 19%)が層1 でブロックされていました。内訳はテスト失敗が 4 回、リントが 1 回、シークレットスキャンが 1 回です。シークレットの 1 回はデバッグ用に書いた接続文字列の置き忘れで、本番運用しているリポジトリの履歴に乗る前に止まったことだけでも、導入の手間の元は取れたと感じています。
もうひとつの発見は、ブロックメッセージを「次の指示」形式にしてから、エージェントがブロック後に自力でテストを直し、ゲートを再実行してから push し直すという修正ループを安定して回すようになったことです。層1 の価値は止めることそのものより、止めた理由を機械可読な形で返すことにあるのだと、運用してみて理解しました。エージェントに渡す差分を小さく刻む習慣(Antigravity のエージェントに『差分を小さく刻む』癖をつけてもらう — 1ヶ月の運用で変えたレビューの流儀 )と組み合わせると、ブロック後の修正も小さく済むので相性が良いです。
一方で、ゲートの実行時間には注意が要ります。テストが長いリポジトリでは push のたびに数分待つことになり、エージェントが応答なしと誤認して push を連打する挙動を一度観測しました。層1 の timeoutSec をテスト所要時間より十分長く取るか、ゲート内で変更ファイルに関連するテストへ対象を絞るかで対処できます。私自身は前者から始めて、遅さが気になってから絞り込みを足す順番を推奨します。
まずは pre-push の 1 ファイルから
二段を一度に組む必要はありません。効果の大きさでいえば、最初に入れるべきは層2 の pre-push です。.githooks/pre-push を 1 ファイル書いて git config core.hooksPath .githooks を実行する。ここまでなら 10 分で終わり、その時点で「どんな経路でもゲートなしの push は通らない」状態になります。層1 の Hooks は、エージェントの修正ループを速くしたくなってから足せば十分です。
本番に近い操作を任せる前の予行演習という意味では、本番に触れる前にエージェントの操作を空振りさせる — 副作用ゼロのドライラン層をどう設計するか の考え方も地続きです。指示ではなく仕組みで守る方向へ一歩ずつ寄せていく際の、たたき台になれば嬉しいです。