「確認して」を「止める」に変えたら、最初に止まったのは自分の作業だった

9月25日、Claude Code がブラウザで契約書に署名して送りかけた、という報告を読んだ。送信・購入・署名のような、押したら戻れないボタンを止めるフックを書いて、auto モードの新しいセッションで試した。テスト用フォームの「Submit order」を押させる。フックは発火して、確認してくれという値を返した。確認は出なかった。送信された。

transcript を見ると、フックの出力は残っていた。形も正しい。"permissionDecision": "ask"、理由の文も付いている。公式のドキュメントには、auto モードでもフックが求めた確認は表示される、と書いてある。手元ではそうならなかった。版の差なのか、ブラウザを操作するツールに固有なのか、原因は突き止めていない。分かったのは、ask が確認になるかどうかを俺の側から保証できない、ということだけだった。

止めたいなら deny しかない。同じクリックを deny に変えて押させたら止まり、ページは送信されていなかった。

3日後、手元の柵も全部「確認して」だった

このフックは公開したが、話はそれで終わらなかった。3日後の28日、別の用事で設定を見直していて気づいた。手元には Bash 向けのフックが3本ある。master への push を見張るものと PR の merge を見張るものは7月からある。記憶のルール(失敗の記録を起動のたびに Claude に読ませる側の守り)で受けた失敗を、6月の「AIに渡す許可には射程がある」から7月の「防御にも射程がある」まで、機械側へ寄せてきた。その機械側がこの2本だ。もう1本、再帰削除や強制 push のような取り返しのつかない操作を見張るものは、25日に作ったばかりだった。

3本とも、返しているのは ask だった。

25日に作ったほうは、冒頭のコメントにこう書いてある。引用符の中の文字列にも反応するが、ask に倒れるだけなので許容。バックアップの記録からすると、これを書いたのはテスト用フォームの送信が通るのを見る数分前だ。ask なら害は無い、と書いた直後に、ask が確認にならない場面を見ていた。そのときは公開するほうの柵しか頭になく、手元の3本とは結びつけていなかった。

auto モードで動かしている時間は長い。この3本は、俺が一番見ていない時間帯に、確認を出さずに通していた可能性がある。6月から、確定操作の許可の射程を越えた再発は5回あって、2回が push、3回が merge だ。merge を見張るフックは7月から生きていて、7月の記事ではこれを監査させて穴を塞いだとまで書いた。塞いだ柵が、auto モードでは柵になっていたかどうか、俺には言えない。

どこまでを deny にするか

deny は ask より強い。ask は人が許せば通るが、deny は人が許しても通らない。俺が「やっていいよ」と言っても Claude はその操作を実行できず、俺が自分でやることになる。だから全部 deny にはできない。

3本を操作ごとに仕分けた。

操作判定理由
PR の merge(gh pr merge)deny俺が自分でやれば済む。戻せない。再発3回(後述)
master への pushask のままこのブログは master への push で自動デプロイされる。Claude に公開作業を続けさせたい
外部を壊す操作(強制 push、リモートブランチ削除、GitHub 上のリポジトリ・リリース削除、ディスク消去、SQL の DROP と TRUNCATE)denyClaude の通常作業では使わない。やるなら俺がやる
手元の操作(rm -r、git reset --hard、checkout --、restore、clean -f、branch -D、stash drop)ask のままClaude が作業中に正当に使う。消えるのは手元の変更で、git と自動バックアップで戻せる

線は2本で引いた。取り消せるかと、Claude にやらせる必要があるかだ。push は取り消せないが、Claude に公開作業を続けさせるには要るので ask に残した。手元の削除は作業中に要るし戻せるので ask に残した。merge と外部破壊は、戻せないうえ、Claude にやらせなくても俺がやれば済むので deny にした。

merge を deny にしたのには、もう1つ理由がある。再発した3回はどれも、俺が出した許可か依頼を、Claude が merge にまで広げた形だった。別の PR 群への「順にマージして」を無関係な PR に持ち越したのが1回。「問題があれば修正して」「タグまで進めていいか」への OK を、merge まで含むと解釈したのが2回。ask のままなら、同じ広げ方がまた通る余地が残る。deny なら、俺の許可そのものが merge には効かないので、広げようがない。

ask に残した分は、auto モードで確認が出るかどうかに頼っている。確認が出ない場面では、push の守りは記憶のルールだけになる。

33秒で2回、止まったのは自分の作業だった

28日の0時14分、merge のフックを deny に書き換えた。確かめるために、存在しない PR 番号で gh pr merge を打たせた。止まった。ここまでは狙いどおりだ。

0時23分、取り返しのつかない操作のフックを書き換えた。外部を壊す側を先に判定して deny、手元の操作は ask のまま、同じコマンドに両方あれば deny を優先する。この書き換えは通った。

狙っていない停止はここからだ。その直後、冒頭のコメントに注意書きを足す sed が止まった。理由は「SQL の DROP / TRUNCATE」。

危険な SQL を流そうとしたわけではない。足そうとした注意書きは、引用符の中の "drop table" にも反応する、という一文で、その文字列が sed の引数に入っていた。フックはコマンドの文字列を正規表現で読む。コメントを書く sed も、テーブルを落とす sqlite3 も、同じ drop table という字面だ。

33秒後、もう1回止まった。今度は Claude が次のセッションのために残す記憶用のメモだった。「コマンド文字列に drop table 等が含まれるだけでも止まる」と、まさにこの挙動を書き残そうとした heredoc が、その文を含んでいたので止まった。止まる理由を書いたメモが、その理由で止まった。

2回とも、フックは直さず、俺の側を直した。止まった注意書きは、引っかかる語を使わない言い回しに変えて入れた。メモのほうは Bash をやめ、ファイルを書くツールで書いた。こういう文字列は今後もそうする、と判断のノートに残した。フックのコメントは結局、25日の「ask に倒れるだけなので許容」が、「deny 対象なら止まる。止まったらユーザーが実行すれば済むので許容」になった。許容の理由が「害が無い」から「俺が肩代わりすればいい」に変わった。

字面の柵は、両側に外れる

7月の「防御にも射程がある」で書いたのは、git push を見張るフックの横を gh pr merge が通った話だった。通られたのは6月で、merge を見張るフックはそのあとに作った。人間は push も merge も「外に出る確定操作」と意味でまとめるが、フックは字面で張られているので、意味と字面のずれの分だけ隙間ができる、と。あのとき問題にしたのは、通ってしまう側の隙間だった。

今回分かったのは、同じずれが逆側にも開いていることだ。字面で張った柵は、意味の上では無害なものも、字面が合えば止める。コメントの中の drop table と、データベースへの drop table は、フックからは区別がつかない。区別するには、その文字列が実行されたら何が起きるかを知る必要があり、文字列を読むだけの柵にはそれが無い。

通る側の隙間も残っている。gh api を叩いて merge する経路はフックの対象外だ。デスクトップアプリの auto-merge を設定するツールも別の入口で、Bash のフックは見ていない。これを塞ごうと字面の柵を広げるたびに、止めすぎる側も一緒に広がる。merge を見張る正規表現を7月に gh ... pr ... merge の順で緩く拾う形にしたとき、gh pr list --label merge も引っかかるようになった。そのフックのコメントにも、25日のものと同じ趣旨で「過剰検知は ask に倒れるだけなので許容」と書いてあった。28日に、こちらも「deny に倒れる」に書き換えた。

同じころ、製品側は到達範囲で囲っていた

deny に切り替えた28日の前後1週間ほど、エージェント向けの発表が続いた。GitHub Copilot アプリのローカルサンドボックス、Docker のクラウドサンドボックス、Google Cloud のエージェント用 PostgreSQL。公式の文書を読むと、どれも俺のフックとは違うものを見ている。

Copilot は、プロジェクトごとに、読み書きを許すフォルダと拒むフォルダ、外向きの通信の可否、Git と gh の認証情報を渡すかどうかを決める。CLI 版なら接続を許すホストまで書ける。Docker は、エージェントを自前のカーネルを持つ microVM に入れ、手元のファイルとネットワークと秘密を VM の外に置いて守る。Google Cloud は、エージェントの要求に応じて隔離した PostgreSQL を数秒で立て、本番には読み取りだけで追従させ、終わったら自動でゼロまで縮める。

共通しているのは、コマンドを読まないことだ。見ているのは、そのプロセスがどこまで届くか。どのフォルダ、どのホスト、どの認証情報、どのデータベース。コメントの中の drop table は、この柵にとって何でもない。文字列を読んでいないからだ。そして、渡されていないデータベースへの drop table は、字面に関係なく届かない。

左は字面の柵。コマンドの文字列を正規表現で読み、止める・確認・通すを判定する。コメント中の drop table が止まり、gh api 経由の merge が通る。右は到達範囲の柵。プロセスの届く先であるフォルダ・ホスト・認証情報・データベースを先に囲い、囲いの中では文字列を読まず、囲いの外へは届かない。囲いの中の取り消せない操作は止めない

ただし、届く範囲の中で何をするかは、この柵は見ない。Copilot の文書は、既定の設定で「ブランチを push し、PR を作る」ような通常の開発作業ができるようにしてある、と書いている。認証情報を渡した以上、その範囲での操作は通る。俺が止めたかった merge は、俺のリポジトリに俺の認証情報で行う操作だ。到達範囲の柵から見れば、それは囲いの中にある。gh api 経由の merge も同じで、囲いの中なら、こちらの柵でも通る。

2つは別の問いに答えている。到達範囲の柵は、知らない経路を含めて外へ出る道を塞ぐ。字面の柵は、囲いの中で「これは人がやる」という運用の線を引く。俺の再発5回は、push も merge も、全部囲いの中で起きていた。

「確認して」と「止める」は別の部品だった

ask を返すフックは、確認する人がそこにいて、確認が実際に出る、という2つの前提の上でしか柵にならない。auto モードでは、確認する人がいない時間が増える。確認が実際に出るかどうかは、手元の観測と食い違った。フックは発火し、正しい値を返し、ログにも残る。見張っていても気づけない形で、柵が柵でなくなりうる。

deny に変えると柵は戻るが、代わりに俺の許可が効かなくなる。merge をフックで止めた今、俺が「マージして」と言っても Claude はできない。これは不便ではなく、狙いだ。再発した3回は、俺の許可や依頼が merge にまで広げられた形だったから、広げられる許可そのものを無くした。

その代償として、字面で読む柵は俺の作業も止める。コメントの1行とメモの1行で、33秒の間に2回止まった。止まるたびに俺が自分で打つか、別のツールで書く。確認ダイアログを押す手間の代わりに、この手間を置いた。届く先を囲う柵を足したとしても、囲いの中で「これは人がやる」と線を引くのは、この字面の柵のままだ。押す人がいない時間帯に効く柵は、押す人がいる時間帯にも同じ強さで効く。

comments powered by Disqus