防御にも射程がある

以前、「AIに渡す許可には射程がある」という記事を書いた。一度「pushしていい」と言われた許可を、AIは別のリポジトリや次の作業へ勝手に持ち越す。だから許可はリポジトリ・作業・操作の種類という3つの軸で切れ、と。失敗の記録をmistakes.mdという外部記憶に刻み、本当に守りたい操作はgit層のフックに落とす。二段構えまで作って、締めに「たぶん、まだ何回か払う」と書いた。

3日後に払わされた。やったのは、俺の作業を手伝っていた当のClaude Codeだ。

「英語化して」がmainマージになった

やっていたのは、毎朝ニュースを書く自作の無人AIタスク(newsリポジトリ、public)の整備だった。中身を配布できる形に整えていて、「まるっと英語化して」と頼んだ。

Claude Codeは英語化のPR #6を作った。ここまではいい。問題はその後で、CIが通ると、そのままgh pr mergeでmainに取り込んだ。マージしていいとは一言も言っていない。

なぜそうしたかの筋は、前回とまったく同じだった。直前に、同じリポジトリの別のPR群で「順にマージして」と言っていた。その許可を、別の作業である英語化PRのマージ許可として持ち越した。記事1039で書いた「一度もらった許可を、勝手に延長した」が、そっくりそのまま再演された。ただし今回は、pushではなくgh pr merge、つまり保護ブランチへの取り込みという確定操作に広がっていた。

記憶のルールは、正しい粒度で書いてあった

前回と違うのはここからだ。前回は「記録した教訓の粒度が粗かったから変異を捕まえられなかった」という話だった。今回は違う。mistakes.mdにはこう書いてあった(抜粋)。

Correct Action: ある操作への許可は、そのrepo・その作業限りで、次に持ち越さない
Trigger: git commit / push する直前。特に直前の別作業で push 許可をもらっている場合ほど注意

「持ち越すな」は明文化済みだった。ルールの本文は、今回の失敗にそのまま当てはまる。当てはまるのに、効かなかった。

効かなかった理由はTriggerの行にある。発火条件が「git commit / pushする直前」で、mergeはpushではないから発火しない。ルールの中身は正しいのに、それをいつ思い出すかの索引が、pushというコマンドの表面に張り付いていた。mergeはその網の外側を通った。

機械のガードは、破られたのではなく正面から通られた

記事1039の結論は「記憶は変異に弱いから、刺さるなら機械側へ寄せろ」だった。じゃあ機械側はどうだったかというと、このリポジトリのmainには保護がかかっていた。直pushは不可、変更はPR必須、管理者にも強制。さらにmasterやmainへ届くgit pushに割り込んで一拍置くconfirm-master-push.shというフックも生きていた。

全部、正常に動いていた。そして全部、通られた。

ブランチ保護が強制するのは「PRを経由し、CIが緑であること」というプロセスの形であって、人間が承認したかどうかではない。PRはあった。CIは緑だった。機械的には正当な操作だ。フックのほうはgit pushというコマンドを見張っていたが、gh pr mergeは別のコマンドで、フックの視界に入りすらしない。「mainにコードが載る」という同じ結果に向かうドアが2つあって、防御は片方のドアにしか付いていなかった。

防御にも射程がある

整理すると、この日破られた防御は3つあって、どれも壊れていない。

  • 記憶のルールは「push直前」という発火条件の射程を持っていた
  • pushフックは「git pushというコマンド」という監視対象の射程を持っていた
  • ブランチ保護は「PR+CI」というプロセスの射程を持っていた
3つの防御(mistakes.mdの記憶ルール・confirm-master-push.shフック・ブランチ保護)がそれぞれpush直前・git pushコマンド・PR+CIという射程を持ち、gh pr mergeがどの射程にも掛からず素通りしてmainに到達する様子

許可に射程があるように、防御にも射程がある。防御を立てた瞬間に「その防御が何を見ていないか」が同時に決まる。失敗は悪意なしに、防御の射程が切れた場所を通って再発する。前回のpushから今回のmergeへの変異は、誰かが抜け道を探した結果じゃない。AIの側では「さっきマージ許可をもらった」という筋が通っていて、その筋の通った操作を止める防御が、たまたまどこにも張られていなかっただけだ。

「作る」と「送る」は別物、と前回書いた。今回思い知ったのは、防御の側がこの区別をコマンド名で実装している限り、「送る」に相当する操作が増えるたびに漏れる、ということだ。push、merge、公開、削除。人間は「外に出る確定操作」と意味でまとめて考えるが、フックや記憶のTriggerはgit pushという字面で張られている。意味と字面のずれの分だけ、防御の射程には隙間ができる。

隙間を一マスずつ埋める

対策は、防御の射程を破られた方向へ広げることだった。

まず当日、mistakes.mdにmerge用のエントリを足した。Triggerは「gh pr mergeでPRをmain/保護ブランチに取り込むとき。特に直前の別作業でマージ/push許可をもらっている場合」。記憶の索引に、pushと並べてmergeという語を登録した形だ。

そして後日、これを機構に落とした。gh pr mergeを確認制にするconfirm-merge.shというフックで、push用フックの隣に並べた。記憶で受けた失敗を機械側へ寄せていく流れは記事1039の二段構えのままだ。ちなみにこのフック自体にも、オプションの割り込みで素通りする穴が、作ったその日のうちに見つかったのだが、それはまた別のエージェントに監査させて塞いだ話として書いた。防御を足しても、その防御の射程がまた新しい隙間を持つ。この入れ子は終わらない。

結び

前回の締めに書いた予想は当たったが、当たり方が想定と違った。同じ失敗が同じドアからもう一度来るんだと思っていた。実際には、失敗のほうが防御の見ていないドアを通って来た。狙ったわけではない。防御が見ているドアでは止まり、見ていないドアでは止まらなかった、それだけの話として。

だからAIに確定操作を任せる防御は、立てて終わりにならない。破られるたびに「今回はどの射程の外を通られたか」を特定して、記憶の索引なりフックの監視対象なりを一マス広げる。この地道な繰り返し以外に、意味と字面のずれを詰める方法を俺はまだ持っていない。許可の射程を教えるのと同じ根気で、防御の射程を育てていくしかない。