ブランチを切っただけだった。コマンドは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
意図は正しい。「リモートの最新を起点にブランチを切る」は、
むしろgit pullを挟み忘れるより安全に見える書き方ですらある。
実際、私は出てきたコマンドを見て、止める理由を何も思いつかなかった。
そこから先の流れは、順に並べたほうが分かりやすい。
① origin/master 起点でブランチ作成
└─ この時点で upstream が master を向いている(誰も気づかない)
② コミット & PR 作成
── ここから約 5 時間、何も起きない ──
③ upstream 宛ての push(私が実行)
├─ コミットが master に直着弾
├─ PR が自動で MERGED 扱いに(マージボタンは誰も押していない)
└─ master 更新をトリガにステージングへ自動デプロイ
④ revert PR をマージ、master は事故前の状態に完全復旧(③ の 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 pullgit 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 素の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つに頼らない。経路が違えば、効く防御も違う。
全員・全経路に有効
危険なコマンドを実行前に遮断
そもそも罠を踏まない
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 → ブランチ削除禁止 ここが今回いちばんうまくいった設計だ。 「穴を塞ぐと仕事が止まる」という制約に対して、 禁止したいもの(直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.jsonにdenyルールを置いた。
{
"permissions": {
"deny": [
"Bash(git checkout * origin/master)",
"Bash(git switch -c * origin/master)",
"Bash(git branch * origin/master)"
]
}
} 実機で確認できたことが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」と出れば正常
そして運用は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を信用するかどうかの判断ではなく、危険な操作をそもそも実行できない環境をつくることだと思う。 失敗の後に「気をつけます」と書き足すのがいちばん効かない。 気をつけなくても踏めない場所に、罠を移す。