git push origin main は拒否される。git -C ~/path/to/repo push origin main は通る。同じマシン、同じ設定、同じセッションでの話だ。「同じ push では?」と言いたくなるが、ガードの側から見ると同じではなかった。
差はグローバルオプションが1つ挟まっただけ。禁止ルールを増やし忘れていたわけでも、書き方を間違えていたわけでもない。判定そのものが起動していなかったのが理由だった。
以前この場所で、Claude Code(Pro $20/月、Max $100〜200/月)の安全設計について「Hooks をやめて permissions.deny のワイルドカードだけで完結させた」「*:main パターンで Hooks の動的 refspec 解析と同等のブロック精度を実現した」と書いた(Claude Code の Hooks と Permissions 使い分け)。その前提を実測で確かめたら、あっさり崩れた。ワイルドカードは強力だが、強力なのはマッチした後の話であって、マッチする前の話ではなかった。
この記事で扱うこと
- コマンド文字列の前方一致でガードを組むと、なぜ素通りが起きるのか
- 実測で見つかった8通りの抜け道(
-C/ 連続空白 / 複数 refspec /HEADなど) - 「起動条件そのものが前方一致だった」という一段メタな見落とし
- 直しすぎて起きた過剰検知と、それを消すための判定順序
- pre-commit フック・
sudoラッパー・CI の禁止コマンドチェックへの読み替え
対象は Claude Code に限らない。コマンド文字列を見て可否を決める仕組みを1つでも運用しているなら、同じ穴が空いている可能性がある。
前提:ガードは2層あって、両方とも文字列を見ている
Claude Code で危険なコマンドを止める手段は大きく2つある。
| 層 | 仕組み | 判定材料 |
|---|---|---|
| 権限設定 | permissions.deny に Bash(git push *:main) のようなパターンを列挙 | コマンド文字列のパターンマッチ |
| フック | PreToolUse で外部スクリプトを起動し、標準出力で allow / deny / ask を返す | スクリプトが受け取ったコマンド文字列 |
役割分担の考え方自体は今でも有効だと思っている(詳しくはHooks 安全設計パターン)。問題は、どちらの層も入力がただの文字列だという点にある。文字列を人間が読むように解釈してくれる主体は、どこにもいない。
誤解のないように補足しておくと、permissions.deny のパターンはグロブで、* は空白をまたいで任意の文字列にマッチする。公式ドキュメントの例でいえば Bash(git * main) は git push origin main にも git merge main にもマッチする。表現力は十分にある。問題は、Bash(git push *:main) のように * で始まらないパターンは先頭が literal で固定されることだ。git と push の間に何かが挟まった時点で、そのパターンは候補にすら入らない。公式ドキュメント自身も、引数を制約しようとする Bash のパターンは壊れやすいと明記している。
以下で実測ログとして挙げるのはフック経路の挙動だ。禁止ルール側については、先頭が固定されたパターンが同じ変形で外れることはパターンの構造から言えるので、実測を待つまでもない。
実測:素の push は止まり、オプションを1つ挟むと通る
きっかけは、コンテナ経由で AI エージェントにタスクを投げる構成のリスク分析だった。保護ブランチへの push を止めるフックが本当に効いているかを、コマンドを変形させながら順に食わせていった。
実測の記録はこうだ。素の git push origin main は deny される一方で、git -C <ディレクトリ> push origin main はフックが何も出力せず、そのまま allow された。何も出力しないというのは、拒否されなかったのではなく、push コマンドだと認識すらされなかったということだ(正直、沈黙は同意という言い回しがここまで似合わない場面もない)。
判定側は git push という文字列の前方一致で「push 系かどうか」を決めていた。git と push の間に何かが入った瞬間、その前方一致は外れる。そして git のグローバルオプションは、まさにそこに入る。
そこから先は、外れ方のバリエーション探しになった。以下は一度に出てきたものではなく、数日おきの2ラウンドで積み上がっている。1ラウンド目で潰し切ったと思いきや、2ラウンド目でまた別の外れ方が出てくる、という順序だった。
| 入力コマンド | 期待 | 実測の結果 |
|---|---|---|
git push origin main | 拒否 | 拒否(正常) |
git -C <別リポジトリ> push origin main | 拒否 | 素通り |
git --git-dir=<パス> push origin main | 拒否 | 素通り |
git -c <name>=<value> push origin main | 拒否 | 素通り |
git --attr-source <値> push origin main | 拒否 | 素通り |
git push origin main(空白が2つ) | 拒否 | 素通り |
git push origin feat:feat dev:dev | 拒否 | 素通り |
git push --all / git push --mirror | 拒否 | 素通り |
git push origin HEAD | 拒否 | 素通り |
最後の3つは前方一致とは別の外れ方をしている。複数 refspec のケースは、宛先をひとつ(先頭のもの)しか見ていなかったので、2つ目以降に保護ブランチを書けば検出を丸ごとバイパスできた。--all / --mirror はそもそも宛先がコマンドラインに現れないので、宛先を読む判定は空振りする。git push origin HEAD と @ は、宛先が HEAD や @ という文字列のまま残り、保護ブランチ名との比較が一致せずに黙って通っていた。
黙って通る、が一番たちが悪い。deny なら気づくし、ask でも気づく。何も起きないことは、安全であることの証拠にならない(このあたりの分岐はfail-open / fail-closed の設計で書いたとおり)。
一段メタな見落とし:フックが起動していなかった
判定スクリプトのトークン解析を書き直し、回帰テストも足した。ここで終わったつもりだった。
ところがレビューで、もっと手前の話が出てきた。フックを起動する側の設定、つまり「どのコマンドのときにこのスクリプトを呼ぶか」という条件が Bash(git push*) の前方一致のままだったのだ。
つまり git -C <ディレクトリ> push ... では、スクリプトが呼ばれてすらいなかった。中身をどれだけ厳密にしても、実運用の経路では1行も実行されない。テストは通る。実機では素通りする。偽陰性としてはかなり質が悪い部類だ。
修正は起動条件を Bash(git*) に広げるだけ。1行だ(いや、そこに辿り着くまで、直す必要のなかったコードをずいぶん丁寧に磨いたのだが)。だが、この1行を直すまで、スクリプト側の修正には運用上の意味がゼロだった。
ガードを直すときは、上の分岐から順に疑う。中身から疑うと、B で落ちていることに最後まで気づけない。
表に挙げた抜け道のうち、-C と --attr-source の2つには、素通り以外の面倒が付いていた。
ひとつは、-C で別リポジトリを指した場合の話。refspec を省略した push では現在のブランチ名を解決してから判定するのだが、その解決がフック実行時のカレントディレクトリ基準のままだった。feature ブランチのディレクトリから -C で main リポジトリを指して push すると、見ているブランチと push されるブランチが食い違う。allow すべきものを deny し、deny すべきものを allow する。位置指定オプションを読み飛ばすだけでは足りず、同じ位置指定を状態の問い合わせ側にも再現する必要があった。
もうひとつは --attr-source(git 2.41 以降で使える値取りのグローバルオプション)。読み飛ばし対象のリストに載っていなかったため、次のトークンが「知らないサブコマンド」と判定され、そのまま終了コード0で通っていた。典型的な fail-open だ。個別に足すのは当然として、将来また未知のオプションが増えることは確実なので、知らない値取りオプションに遭遇した場合でも残りのトークンに push が実在するなら通さず ask に倒す、という防御を重ねた。
直しすぎた:git stash push が確認を求めてくる
ここで往復が発生する。
「未知のオプションの後ろに push があれば ask」という防御を素朴に実装したところ、トークン列のどこかに push という文字列があるだけで確認を求めるようになった。結果、これらが軒並み引っかかった。
| 入力コマンド | 期待 | 過剰検知したときの挙動 |
|---|---|---|
git stash push | 何もしない | 確認を求める |
git log --grep push | 何もしない | 確認を求める |
git commit -m push | 何もしない | 確認を求める |
git help push | 何もしない | 確認を求める |
どれも保護ブランチとは無関係な日常操作だ(git stash push のたびに確認ダイアログが挟まる開発体験は、想像するだけで手が止まる。まあ、安全ではある)。
原因は走査開始の条件を欠いていたこと。直前のトークンが何だったかを見ずに、いきなり push 探しを始めていた。修正は「直前のトークンが - で始まるとき、つまり未知のオプションの値かもしれないときに限って走査する」というもので、git の直後や通常トークンの直後に来た非オプションは、実サブコマンドが確定したとみなしてそこで判定を打ち切る。
ゆるすぎるガードを締めると、必ず一度は締めすぎる。締めすぎを検知できるのは、通ってほしい操作のテストを持っているかどうかにかかっている。落ちてほしいケースだけ集めたテストでは、この回帰は捕まらない(フックのトラブルシューティングで扱った症状と、根っこは同じだ)。
結局どう直したか
判定方法を、文字列の前方一致からトークン列の走査に置き換えた。
1. コマンドを空白で分割してトークン列にする(連続空白もここで吸収される)
2. 先頭から辿り、値を取るグローバルオプションを「値ごと」読み飛ばす
-C / -c / --git-dir / --work-tree / --namespace /
--exec-path / --config-env / --super-prefix / --attr-source
3. 最初の非オプショントークンが push かどうかで判定する
4. push 以降の引数は、文字列の prefix 除去ではなく
確定した push の位置からの配列スライスで取り出す
5. refspec は全件走査し、ひとつでも保護対象なら deny
6. 宛先を列挙できない --all / --mirror は ask に倒す
4番目は地味だが外せない。グローバルオプションの分だけ位置がずれるので、"git push" を文字列として削り取る方式では引数の頭がずれる。
ただし2の一覧は、手で維持している時点ですでに賞味期限がある。--attr-source が後から増えたのと同じことは今後も起きるし、逆に消えるものもある(--super-prefix は後の git で削除された)。値の取り方に例外があるものもある(--exec-path は = を付けなければ設定値を表示して終わるだけで、次のトークンを消費しない)。一覧の網羅性で守ろうとすると、また同じ形で破れる。効くのは3の「知らないものに出会ったら止める」ほうだ。
回帰テストは、既存39件に新規8件を足した計47件の実測で通した。そのあと起動条件と -C 系の修正を入れた段階では、新規9件を追加して計56件。この9件は旧スクリプトに対して先に実行し、全件が意図通り失敗することを確認してから反映している。テストを書いてから直す、をガード修正でやる価値は高い。書いたテストが本当に穴を突けているかを、直す前に一度だけ確かめられるからだ。
判定の全体像はこうなった。
手元の設定で試してみる
自分の禁止ルールは、本当にマッチしているのだろうか。変形コマンドを1つ食わせれば数分でわかる。まず、いま何を禁止しているつもりなのかを一覧する。
jq -r '.permissions.deny[] | select(startswith("Bash(git"))' ~/.claude/settings.json
次に、以下を1つずつエージェントに実行させる。実際に push されると困るので、git init した使い捨てのリポジトリを用意し、リモート URL を存在しない場所に向けておくと安全に試せる。
git push origin main
git -C ~/path/to/repo push origin main
git -c user.name=probe push origin main
git push origin feat:feat main:main
git push origin HEAD
git push --all
1行目が拒否され、2行目以降のどれかが通ったなら、そのガードは前方一致でマッチしているだけだ。通ったものが1つでもあれば、ルールの追加ではなく判定方式の置き換えが必要という切り分けになる。
締めすぎの確認も同時にやる。以下は全部素通りするのが正解だ。
git stash push
git log --grep push
git commit -m push
git help push
同じ穴が空いている場所
この話の一般形は「コマンド文字列を見て可否を決める仕組みは、文字列の書き換えで回避できる」に尽きる。手元の環境でいうと、だいたいこのあたりに同じ構造がある。
| 仕組み | ありがちな判定 | 回避される例 |
|---|---|---|
| pre-commit フック | 対象ファイルの拡張子やパスの前方一致 | シンボリックリンク経由・相対パスの書き換え |
sudo ラッパー | 許可コマンド名の前方一致 | 絶対パス指定・環境変数の前置・シェル経由 |
| CI の禁止コマンドチェック | スクリプト内の文字列 grep | 変数展開・改行の挿入・エイリアス |
| デプロイ承認ゲート | 環境名の完全一致 | 別名・エイリアス・大文字小文字の違い |
対策の方向は共通していて、(1) 文字列ではなく構造、つまりトークンや AST やパース済みの引数で判定する、(2) 判定できなかったときに通すのではなく止める、(3) 通ってほしい操作のテストも持つ、の3点になる。どれも派手さはないが、増やしたルールの数よりずっと効く。
そしてもうひとつ。今回の一連の修正でも、根本的な話は残っている。クライアント側のガードは、クライアント側で書き換えられる。保護ブランチをサーバー側で強制する設定を入れておけば、判定ロジックの出来に関係なく最後の一線は守られる。手元のガードは事故を減らす道具であって、迂回不能な防御層ではない。
まとめ
守りを厚くしたつもりの設定ほど、「マッチしていない」に気づきにくい。ルールの数は増えるので、増やした分だけ安心してしまうからだ。
- 前方一致は、間に何かが挟まった瞬間に外れる。git のグローバルオプションはまさにそこに挟まる
- 判定ロジックより先に、判定が起動する条件を疑う。起動していなければ中身は無関係
- 知らない入力に出会ったときの既定を「通す」から「止める」に変える
- 締めすぎの検知には、通ってほしい操作のテストが要る
- 手元で試すコストは数分。禁止したつもりのコマンドを、変形させて1回食わせてみる
AI エージェントに実行権限を渡す構成では、この手のガードが唯一のブレーキになっている場面が増えている。設計から実装まで含めて相談したいときはAI実装支援も見てほしい。まずは自分の deny ルールに git -C を1回食わせるところからで十分だ。



