ブランチを切っただけだった。コマンドは1行、実行時間は0.1秒。 しかも、そのコマンドを打ったのは私ではない。Claude Codeだ。 それから約5時間後、レビュー前のコミットがmasterに載り、 PRは誰もマージボタンを押していないのにMERGEDになり、 ステージング環境が勝手にデプロイされた。
この記事の要点
  • git checkout -b feature/X origin/master は、gitの既定動作でupstreamがmasterを向く
  • この形を選んだのはAI。自分が普段使わない書き方でも、任せていれば実行される
  • 素のgit pushは守ってくれるが、GUIのpushボタンやrefspec指定は守ってくれない
  • 従来型のbranch protectionはenforce_admins: falseだと管理者に対して無防備
  • 対策はサーバ側・ツール側・人間側の3層。Claude Codeにはdenyでコマンドごと取り上げた

最初に書いておくと、これは「誰かがミスをした話」ではない。 実行されたコマンドは、gitの教科書にも載っている普通の形だ。 gitの既定動作と、保護設定の穴の組み合わせで、誰にでも起きる。

そして私にとっていちばん効いた教訓はここだった。 私は普段、この書き方をしない。 ブランチはローカルのmasterを最新にしてから切るのが手癖で、 origin/masterを起点に指定したことはほとんどない。 それでも事故は起きた。コマンドを選んだのが私ではなかったからだ。

1. 何が起きたか

「このチケットの作業ブランチを切って」——Claude Codeにそう頼んだ。 返ってきたのは、まったく妥当に見えるコマンドだった。

git checkout -b feature/ABC-1234 origin/master
事故の起点。リモートの最新から切るという意図としては正しい。この1行で、新しいブランチのupstreamが master に設定される

意図は正しい。「リモートの最新を起点にブランチを切る」は、 むしろgit pullを挟み忘れるより安全に見える書き方ですらある。 実際、私は出てきたコマンドを見て、止める理由を何も思いつかなかった。

そこから先の流れは、順に並べたほうが分かりやすい。

①  origin/master 起点でブランチ作成
    └─ この時点で upstream が master を向いている(誰も気づかない)

②  コミット & PR 作成

    ── ここから約 5 時間、何も起きない ──

③  upstream 宛ての push(私が実行)
    ├─ コミットが master に直着弾
    ├─ PR が自動で MERGED 扱いに(マージボタンは誰も押していない)
    └─ master 更新をトリガにステージングへ自動デプロイ

④  revert PR をマージ、master は事故前の状態に完全復旧(③ の 12 分後)
⑤  ブランチ保護ルールを適用
発生から復旧まで12分。本番影響はなかったが、原因の特定にはそこから丸一日かかった

復旧が速かったのは運が良かっただけだ。 怖いのはブランチを切ってからpushするまでの約5時間、何も異常が見えなかったことのほうだ。 ブランチは正しい名前で切れていて、コミットも正しく積まれていて、PRも普通に立っていた。 爆弾は、pushする瞬間まで爆弾に見えない。

2. 原因A——リモート追跡ブランチを起点にすると、upstreamが引きずられる

gitにはbranch.autoSetupMergeという設定があり、既定では有効になっている。 これはリモート追跡ブランチ(origin/masterなど)を起点にブランチを作ると、 自動でそのリモートブランチをupstreamに設定するという挙動だ。

つまりfeature/Xという名前のブランチなのに、 「このブランチのpush先はmasterです」という札が最初から貼られている。

危険な形

  • git checkout -b feature/X origin/master
  • upstreamがorigin/masterになる
  • 見た目には何の警告も出ない

安全な形

  • git checkout master && git pull
  • git checkout -b feature/X
  • upstreamは未設定のまま

起点を明示したいなら--no-trackを付ければ追跡設定は付かない。 そして、この事故を起こしうるコマンドは3つの形がある。

git checkout -b feature/X origin/master
git switch -c    feature/X origin/master
git branch       feature/X origin/master
いずれも「リモート追跡ブランチを起点にする」形。3つとも同じ罠を踏む

素のgit pushは守ってくれる。守ってくれないものがある

ここが今回いちばん学びになった点だ。 push.default=simple(これも既定)のとき、素のgit pushブランチ名とupstream名が一致しないと拒否する。 つまりコマンドラインで普通にpushしているぶんには、gitが止めてくれる。

問題は、その安全装置を通らない経路があることだ。

  • GUIツールのpushボタン — upstreamをそのまま宛先として使う
  • git push origin HEAD:master型のrefspec指定 — 宛先を明示するのでpush.defaultは関与しない
  • gitが出すヒントに素直に従う — 拒否時のメッセージをそのままコピペすると、宛先明示の形になることがある

今回はこの経路だった。 「安全装置がある」ことと「安全装置を必ず通る」ことは別で、 普段GUIを使う人ほど、CLIの安全装置の存在を前提にできない。

3. PRは「マージボタンを押していないのにMERGED」になる

事故のあと、PR画面を見て一瞬混乱した。 紫色の「Merged」バッジが付いていたからだ。誰も押していないのに。

これはGitHubの仕様どおりの挙動だ。 GitHubはPRのheadコミットがbaseブランチに載ったことを検知すると、そのPRを自動的にMERGED扱いにする。 マージ操作をしたかどうかは見ていない。「結果としてbaseに入っているならマージ済み」という判定だ。

この挙動のせいで、事故は「正常にマージされた」ように見える。通知も普通のマージ通知として届く。直pushとマージを見分けたいときはmergeCommitのSHAを見る——マージコミットが存在しない、あるいはSHAが直pushしたコミットそのものなら、ボタンは押されていない。

4. 原因B——「保護されているはず」が保護されていなかった

もうひとつの原因は、もっと単純で、もっと痛い。 masterには従来型のbranch protectionが設定されていた。 設定画面には「直pushを禁止する」と書いてあった。効いていなかった。

従来型 branch protection の設定値

  enforce_admins      : false   ← 管理者は全ルールをバイパス
  allow_force_pushes  : true    ← force push も可能

  つまり「master 直 push 禁止」は、
  管理者権限を持つ人間に対しては 一切かかっていない
設定は存在していた。適用範囲が想定と違っただけだ

そして厄介なことに、この穴には運用上の理由があった。 少人数のチームなので、承認0でのセルフマージを日常的に使っていて、 それが管理者バイパスに依存していた。 enforce_adminsを素直に有効化すると、今度は誰もマージできなくなって業務が止まる

「穴を塞ぐと仕事が止まる」——これは、穴が長年放置される典型的な理由だ。

5. 再発防止は3層で組んだ

どれか1つに頼らない。経路が違えば、効く防御も違う。

サーバ側GitHub Ruleset
全員・全経路に有効
ツール側Claude Codeのdeny
危険なコマンドを実行前に遮断
人間側git設定と確認の習慣
そもそも罠を踏まない

5-1. サーバ側——Rulesetには暗黙のadminバイパスがない

従来型のbranch protectionをやめ、Rulesetに移した。 Rulesetの重要な性質は、バイパスする対象を明示的に指定しないかぎり、誰もバイパスできないことだ。 「管理者は例外」という暗黙のルールが存在しない。

Ruleset: protect-master   (対象: refs/heads/master のみ)

  bypass_actors : (空)        ← 誰もバイパスできない

  pull_request(必要承認数 0)  → 直 push を全員拒否。PR 経由必須
  non_fast_forward              → force push 禁止
  deletion                      → ブランチ削除禁止
承認数を0にしておくのが肝。「直pushだけ禁止・セルフマージの運用は据え置き」が両立する

ここが今回いちばんうまくいった設計だ。 「穴を塞ぐと仕事が止まる」という制約に対して、 禁止したいもの(直push)だけを禁止し、依存していたもの(承認0のセルフマージ)は残した。 ルールを強くするのではなく、ルールの当たる範囲を正確にした

5-2. ツール側——Claude Codeに危険なコマンドを実行させない

ここが本題だ。今回の起点は、まさにここだった。

日常の実装はほぼClaude Codeにやらせているので、ブランチを切るのもClaudeである。 そしてAIは、私の手癖を再現するわけではない。 学習データにある一般的な書き方の中から、その場で妥当なものを選ぶ。 git checkout -b X origin/masterは、世の中に大量にある正しい書き方のひとつだ。 だからAIがこれを選ぶのは、間違いですらない。

ここに、AIに作業を任せるときの見落としがある。 「自分がやらないミス」は、自分のレビュー観点に入っていない。 出てきたコマンドを見ても、踏んだことのない罠には気づけない。 私はこの1行を承認しているが、危険だと思って見逃したのではなく、 危険だと知らなかった

もっと正直に書くと、私はこのコマンドをろくに読んでいない。 「ブランチ切って」と頼んで、出てきたものを流し読みして通した。 危険だと知らなかったのは事実だが、知っていても気づかなかった可能性のほうが高い。 そして最後にpushを実行したのも私だ。ここは擁護のしようがないポカだ。

ただ、だからこそdenyに意味がある。 AIの出力を1行ずつ検分し続けられる人間は、そう多くない。 1日に何十回もブランチを切って、コミットして、pushする作業のなかで、 毎回すべてのコマンドを疑ってかかるのは現実的ではないし、 それができるなら最初からAIに任せていない。 「次からよく見ます」は対策になっていない—— 注意力に依存する対策は、注意力が落ちた日に必ず破られる。

そこで、リポジトリ共有の.claude/settings.jsondenyルールを置いた。

{
  "permissions": {
    "deny": [
      "Bash(git checkout * origin/master)",
      "Bash(git switch -c * origin/master)",
      "Bash(git branch * origin/master)"
    ]
  }
}
先ほどの3つの形をそれぞれ塞ぐ。設定ファイルをリポジトリにコミットするので、チーム全員のClaudeに同じ制約がかかる

実機で確認できたことが2つある。

  • 中間ワイルドカードが効く — ブランチ名の部分が可変でもマッチする
  • 複合コマンドの各パートにも効くcd x && git checkout ...のように連結しても、内側のgitコマンドで止まる

重要なのは、これが「そうしないでね」というお願いではないことだ。 以前トークンを溶かしたときにも書いたが、 AIの挙動を確実に変えたいなら、言葉で禁じるより能力を取り上げるほうが速い。 プロンプトに「origin/master起点で切らないこと」と書いても、 それは数百行の指示のうちの1行にすぎず、文脈次第で薄まる。 denyは実行前に機械的に止まるので、薄まらない。

ただしdeny例外を表現できない。安全な--no-track付きの形も、文字列としてマッチするので一緒にブロックされる。これは仕様として受け入れて、運用を「ブランチはローカルmasterから切る」に統一した。抜け道を残すより、やり方を1つに絞ったほうが事故が減る。

もうひとつの制約は適用範囲だ。 denyが効くのはClaude経由の操作だけで、 ターミナルの直打ちにもGUIツールにも効かない。 だから3層なのであって、ツール側の防御だけでは穴が残る。 そこはRulesetが受け止める。

5-3. 人間側——そもそも罠を踏まない設定にする

# リモート起点で切ったときの自動 upstream 設定を止める
git config --global branch.autoSetupMerge simple

# ブランチを切った直後に追跡先を確認する
git rev-parse --abbrev-ref @{upstream}
#   → 「origin/master」と出たら危険。すぐ upstream を外す
#   → 「no upstream configured」と出れば正常
この2行を知っているかどうかで、事故の確率が変わる

そして運用は1つに統一した。 git checkout master && git pull && git checkout -b feature/X。 ローカルのmasterから切れば、upstreamは付かない。それだけで今回の事故は起きない。

6. 対策は、設定画面ではなく実弾で検証した

今回いちばん気に入っている手順がこれだ。 「Rulesetを入れました、たぶん効いています」で終わらせなかった。

とはいえ、本物のmasterに直pushして確かめるわけにはいかない。 そこで、一時的なRulesetと使い捨てのrefを用意し、API経由でrefを更新して実際に弾いてもらった。

HTTP 422
Changes must be made through a pull request.
このエラーが返ってきたら、防御が効いている証拠。確認してから本適用した

そもそもこの事故は「設定してあるから大丈夫」を信じたことが原因の半分だ。 同じ信じ方で対策を打てば、同じ穴が空く。 「防いだつもり」を検出できる唯一の方法は、実際に防がれてみることだ。

運用に入ってからも、これは確認できる。Ruleset適用後に直pushを試みてChanges must be made through a pull request.で拒否されたら、それは邪魔をされたのではなく、設定が正常に働いているということだ。拒否メッセージを歓迎できるようになると、防御は生きたままになる。

7. まとめ

この事故には、悪意も怠慢も、明らかなミスも登場しない。 普通のコマンド、既定の設定、設定済みのブランチ保護。 全部まっとうなのに、組み合わせると穴になった。

  • git checkout -b X origin/masterはupstreamがmasterを向く。3つの形すべてが同じ罠
  • この形を選んだのはAIだった。自分が普段使わない書き方でも、任せていれば実行される
  • 「自分がやらないミス」はレビュー観点に入っていない。踏んだことのない罠は、目の前に出ても気づけない
  • 素のgit pushは守ってくれるが、GUIとrefspec指定は守ってくれない。安全装置は「必ず通る」とは限らない
  • PRはボタンを押さなくてもMERGEDになる。事故が正常なマージに見える
  • 「保護されているはず」を実際の設定値で確認するenforce_admins: falseは管理者に無防備
  • 穴を塞ぐと仕事が止まるなら、ルールを弱めるのではなく当たる範囲を正確にする。承認0のPR必須ルールが答えだった
  • AIに危険な操作をさせたくないなら、お願いではなくdenyで能力を取り上げる
  • ツール側の防御には適用範囲がある。Claudeのdenyはターミナル直打ちには効かない。だから多層にする
  • 対策は実弾でテストする。設定画面のチェックボックスは、効いていることの証拠にならない

そして、この記事でいちばん書いておきたかったのはこれだ。 AIに作業を任せると、自分の「危険なコマンドの引き出し」が広がる。 自分が10年打たなかった形でも、AIは1日で打つ。 それ自体は悪いことではない——自分の手癖より良い書き方を出してくることのほうが多い。 だが手癖の外側には、自分が一度も検証していない領域が広がっている。 「レビューしているから大丈夫」は、レビューできる範囲の中でしか成立しない。

だからといって、Claude Codeにgit操作を任せるのをやめるつもりはない。 今回のような「うっかり」は、人間よりAIのほうがむしろ再現性高く防げるからだ。 人間の注意力は日によってぶれるが、denyは毎回同じように止まる。 一度denyに書けば、そのミスはチーム全員のClaudeから恒久的に消える。 人間相手に同じことをやろうとすると、周知して、覚えてもらって、忘れられる。

必要なのはAIを信用するかどうかの判断ではなく、危険な操作をそもそも実行できない環境をつくることだと思う。 失敗の後に「気をつけます」と書き足すのがいちばん効かない。 気をつけなくても踏めない場所に、罠を移す。