AIに、初めて「書く」を許した日 ― 承認は二通目のメッセージで、期限は10分、そして権限の台帳は三冊目になった話

第40回で、AIからシェルを取り上げました。読み取り専用の6コマンドだけを台帳(allowlist)に載せ、実行係からLLMを降ろし、破壊的な操作は承認の議題にすら上げない。前回はその上に頭脳(planner)を載せ、「moonのつもりでmarsを触る」ノードすり替えを、プロンプトの約束ではなくコードの検証で止めました。

ここまで、このクラスタのAIがサーバに対してやってきたのは「読むこと」だけです。dfを打ち、uptimeを眺め、更新可能なパッケージ一覧を読み上げる。今回はいよいよ、systemctl restartapt-get upgrade――サーバの状態を変えるコマンドを渡します。Phase B、write解禁の日です。

柵を一枚外す前に、三枚立てる

writeを許すにあたって、新しい柵を三枚設計しました。

一枚目は承認の二通目方式。writeを含む指示を投げると、AIはすぐには実行せず、プラン(どのノードで・何を・どのパラメータで)をチャットに表示して止まります。人間がそれを目視し、二通目のメッセージ「承認 plan_xxx」を送って初めてキューに流れる。ここで大事なのは、この承認の判定にLLMがいないことです。二通目は正規表現の完全一致でしか受理されず、「承認 plan_xxx お願いします」と余計な語が付いただけで承認経路には入りません。plan_idは人間が画面から転記する12桁。前回、plannerのノード選択を「推論」から「転記」に格下げしたのと同じ思想を、今度は人間側の操作にも適用しました。何を承認したのかを、雰囲気ではなく転記で確定させる。

二枚目は10分の期限。古いプランをうっかり承認する事故を防ぐため、承認待ちはプラン作成から10分で失効します。期限の強制はAPI側で行い、期限切れの承認はデータベース上のステップを失効扱いに確定させたうえで、理由をそのまま画面に返します。黙って捨てない。

三枚目は三冊目の台帳です。カタログ(何が実行可能か)、承認ポリシー(何に承認が要るか)に続いて、sudoers(OSがrootを許すか)。実行係は非特権ユーザーのまま、sudoersに書いた完全一致の行だけがrootに昇格できます。再起動を許したのはapache2やfail2banなどアプリ層の5サービスのみ。mariadbとsshは、カタログのenumにもsudoersにも載せませんでした。「承認の議題にすら上げない」の続きです。

実装の芯は前回までの教訓の再利用でした。承認処理は「状態をqueuedに確定してからキューに入れる」――第40回の5ミリ秒レースの教訓そのままの順序保証。二重承認や並行承認は、UPDATE ... WHERE status='pending' の更新行数で敗者を弾きます。同じプランを二人(あるいは二重クリック)が承認しても、勝者は一人しかいない。

最初のwriteは、自分の柵に阻まれた

sandboxで158件のテストを緑にして、実機に配備。記念すべき最初の指示は「marsでfail2banを再起動して」でした。プランエコー、承認、投入――そして。

⚠️ mars / svc.restart failed (exit 1・0.005s)
sudo: The "no new privileges" flag is set, which prevents sudo from running as root.

犯人は、Phase Aの自分でした。実行係のsystemd unitに入れていた堅牢化フラグ NoNewPrivileges=yes が、sudoの特権昇格を構造的に禁止していたのです。read専用の時代には「このプロセスから昇格する経路は存在しない」という正しい柵だった。writeの正規経路がsudoになった瞬間、その柵が道を塞いだ。

面白かったのは、半月前の引き継ぎ資料をめくると、当時の自分が付録にこう書き残していたことです。「NoNewPrivileges=yes を入れたので Phase B で sudo が要るアクションを足す場合は要見直し」。予言は当たり、そして読み返されるまで忘れられていました。堅牢化フラグは実質四冊目の台帳で、フェーズが変わるときには棚卸しが要る――そう学んで、NNPを解除し、権限の境界はsudoersの完全一致行へ移しました。

もう一つ、先回りが効いた穴があります。sudoは環境変数を剥いでから対象コマンドを実行する(env_reset)ので、カタログに書いた DEBIAN_FRONTEND=noninteractive はそのままではapt-getに届きません。エラーにもならず、ただ静かに欠落する。sudoersに env_keep を一行同梱して塞ぎました。サイレントな失敗がいちばん危ない、はここでも変わりません。

exit 0・0.558秒

柵を直して再走。プランエコー、「承認 plan_19d2c008f5a6」、そして。

✅ mars / svc.restart (exit 0・0.558s)
[maint] mars / svc.restart → done (status=ok exit=0 dur=0.558s)

このクラスタでAIが初めて、productionサーバの状態を変えた瞬間です。続けて意地悪も試しました。同じ承認をもう一度送ると理由付きで拒否。プランを13分放置してから承認すると――

🔧 承認は受理されませんでした
理由: approval_expired(850s > 600s)

期限の柵も、数字どおりに立っていました。

GitLabが「順番を守れ」と言った

apt系は本人の希望どおり無承認の自動実行にしました(そのために冪等性をカタログで宣言させるfailure_modeフィールドを作ったのです)。「marsをアップグレードして」の初回は、267秒走ってfailed。dpkgのエラーでした。調べると、更新対象だったgitlab-ceの18.9→19.1というメジャーアップグレードが、preinstallスクリプトに拒否されていた。GitLabは18.11を経由しないと上げられない規則なのです。dpkg -lはii、dpkg --auditは無言――展開前の拒否なので、システムは無傷のクリーンな失敗でした。

ここでfailure_mode: idempotent(再実行で収束する)の宣言が実地試験を迎えます。gitlab-ceをholdで対象から外して同じ指示を再投入すると、done(exit 0・1.029秒)。宣言どおり、再実行で収束しました。write解禁の初日に「writeの失敗」を一通り踏めたのは、むしろ幸運だったと思います。失敗時の顔つきまで含めて、設計した通りに動くことを確認できたのだから。

おまけの発見をひとつ。267秒のアップグレードが走っている最中に「ディスク使用量を見て」と投げたら、画面側は90秒で待ちきれず「結果はTalkに届きます」と諦め、その後Talkに0.002秒で完了した実行結果が着弾しました。実行係は一本のキューを順に処理する単純な作りなので、長いwriteの後ろに短いreadが並ぶのです。故障ではなく設計どおりの縮退ですが、「誠実に諦めて、別の窓口で必ず届ける」経路が本番で初めて火を噴いた記録として残しておきます。

柵は、増えた

第40回の締めに、柵は外すためでなく、その内側で走り回るためにある、と書きました。今回外した柵は一枚です。代わりに、承認の二通目・10分の期限・sudoersの完全一致行という三枚を立てた。差し引きで、柵は増えています。それでいいのだと思います。

AIに「書く」を許す作業の実体は、AIを信じる作業ではありませんでした。信じなくても壊れない構造を組む作業でした。プランを目視するのは人間で、承認するのも人間で、その承認すら正規表現の転記でしか通らない。そして自由文からのwriteを承認なしで自動実行することだけは、これからも開けません。手を動かせるようになったAIの隣で、いちばん忙しく手を動かしていたのは、柵を立てる人間のほうでした。