git push origin main は拒否される。git -C ~/path/to/repo push origin main は通る。同じマシン、同じ設定、同じセッションの話だが、ガードから見ると同じではなかった。
差はグローバルオプションが1つ挟まっただけ。禁止ルールを増やし忘れたわけでも書き方を間違えたわけでもない。判定そのものが起動していなかったのが理由だった。
以前この場所で、Claude Code(Pro $20/月、Max $100〜200/月)の安全設計を書いた(前回記事)。「Hooks をやめて permissions.deny のワイルドカードだけで完結させた」という内容だ。その前提を実測で確かめたら、あっさり崩れた。ワイルドカードは強力だが、強力なのはマッチした後の話だった。
この記事で扱うこと
- コマンド文字列の前方一致でガードを組むと、なぜ素通りが起きるのか
- 実測で見つかった8通りの抜け道(
-C/ 連続空白 / 複数 refspec /HEADなど) - 「起動条件そのものが前方一致だった」という一段メタな見落とし
- 直しすぎて起きた過剰検知と判定順序
- pre-commit・
sudo・CI 禁止コマンドチェックへの読み替え
対象は Claude Code に限らない。コマンド文字列で可否を決める仕組みを運用しているなら同じ穴がある。
前提: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 の間に何かが挟まった時点で候補にすら入らない。
以下の実測ログはフック経路の挙動だ。禁止ルール側も、先頭固定パターンが同じ変形で外れることは構造から言える。
実測:1つ挟むと通る
きっかけは、コンテナ経由で AI エージェントにタスクを投げる構成のリスク分析だった。保護ブランチへの push を止めるフックが効いているか確かめた。
実測の記録はこうだ。素の git push origin main は deny される。一方 git -C <ディレクトリ> push origin main はフックが何も出力せず、そのまま allow された。push だと認識すらされなかったということだ。
判定側は git push の前方一致で「push 系かどうか」を決めていた。git と push の間に何かが入った瞬間、その前方一致は外れる。
そこから先は外れ方のバリエーション探しになった。数日おきの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 は宛先の文字列が残り、比較が一致せず黙って通っていた。
黙って通る、が一番たちが悪い。何も起きないことは、安全であることの証拠にならない(fail-open / fail-closed の設計のとおり)。
一段メタな見落とし:フック未起動
判定スクリプトのトークン解析を書き直し、回帰テストも足した。ここで終わったつもりだった。
ところがレビューで、もっと手前の話が出てきた。フックを起動する側の設定、「どのコマンドのときにこのスクリプトを呼ぶか」という条件が Bash(git push*) の前方一致のままだったのだ。
つまり git -C <ディレクトリ> push ... ではスクリプトが呼ばれてすらいなかった。中身をどれだけ厳密にしても実運用では1行も実行されない。テストは通るが実機では素通りする、質の悪い偽陰性だ。
修正は起動条件を Bash(git*) に広げるだけ。1行だ。この1行を直すまでスクリプト側の修正には意味がゼロだった。
ガードを直すときは上の分岐から順に疑う。中身から疑うと B で落ちていることに気づけない。
表に挙げた抜け道のうち、-C と --attr-source の2つには素通り以外の面倒が付いていた。
ひとつは -C で別リポジトリを指した場合の話。refspec 省略時は現在のブランチ名を解決してから判定するが、その解決がカレントディレクトリ基準のままだった。feature ブランチから main を指すと、見ているブランチと push されるブランチが食い違う。位置指定オプションは状態の問い合わせ側にも再現する必要があった。
もうひとつは --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 | 何もしない | 確認を求める |
どれも保護ブランチと無関係な日常操作だ。
原因は走査開始の条件を欠いていたこと。直前のトークンを見ずいきなり push 探しを始めていた。修正は「直前のトークンが - で始まるときに限って走査する」というものです。
ゆるすぎるガードを締めると必ず一度は締めすぎる。締めすぎを検知できるのは通ってほしい操作のテストを持っているかにかかっている(フックのトラブルシューティングと根っこは同じだ)。
結局どう直したか
判定方法を、文字列の前方一致からトークン列の走査に置き換えた。
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 で削除された)。一覧の網羅性で守ろうとすると、また同じ形で破れる。効くのは「知らないものに出会ったら止める」ほうだ。
回帰テストは、既存39件に新規8件を足した計47件で通した。起動条件と -C 系の修正段階で新規9件を追加し計56件。この9件は旧スクリプトに先に実行し、全件が意図通り失敗することを確認してから反映している。
判定の全体像はこうなった。
手元の設定で試してみる
自分の禁止ルールは本当にマッチしているのか。変形コマンドを1つ食わせれば数分でわかる。まず何を禁止しているつもりか一覧する。
jq -r '.permissions.deny[] | select(startswith("Bash(git"))' ~/.claude/settings.json
次に、以下を1つずつエージェントに実行させる。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) 文字列でなく構造で判定する、(2) 判定できなければ通さず止める、(3) 通ってほしい操作のテストも持つ、の3点です。増やしたルール数よりずっと効きます。
そしてもうひとつ。クライアント側のガードはクライアント側で書き換えられる。保護ブランチをサーバー側で強制する設定を入れておけば最後の一線は守られる。手元のガードは事故を減らす道具であって迂回不能な防御層ではない。
まとめ
守りを厚くしたつもりの設定ほど「マッチしていない」に気づきにくい。ルールの数が増えた分だけ安心してしまうからだ。
- 前方一致は、間に何かが挟まった瞬間に外れる。git のグローバルオプションがそこに挟まる
- 判定ロジックより先に、判定が起動する条件を疑う
- 知らない入力に出会ったときの既定を「通す」から「止める」に変える
- 締めすぎの検知には通ってほしい操作のテストが要る
- 手元で試すコストは数分。禁止したコマンドを変形させて1回食わせてみる
AI エージェントに実行権限を渡す構成では、この手のガードが唯一のブレーキになっている場面が増えている。相談したいときはAI実装支援も見てほしい。まずは deny ルールに git -C を1回食わせるところからで十分だ。



