「サーバの様子、見ておいて」――このひと言をAIに頼めたら、どんなに楽だろう。ディスクは足りているか、サービスは生きているか、更新は溜まっていないか。全部、聞けば分かることばかりです。でも、そのためにLLMへシェルを渡すのかと考えると、手が止まります。LLMは平気で嘘をつくし、それらしいコマンドを勝手に「改良」するし、そもそも入力に混ぜ込まれた指示に従ってしまうことがある。相手はよりによって、インターネットに面した公開サーバ(mars)です。
今回は、この「頼みたい、でも怖い」を設計で解いた話です。結論から書くと、答えはAIにサーバを触らせる前に、AIからシェルを取り上げることでした。そしてその安全設計が実機で一気通貫した直後、わずか5ミリ秒の追い越しレースが「最初に通ったのは、たまたまだった」と教えてくれました。
実行係は、AIではない
最初に決めた、いちばん大事な逆転があります。実行係(executor)にLLMを載せない。
公開サーバ側に常駐するのは、推論を一切しない小さなデーモンです。仕事は3つだけ。自分宛てのRedisキューを見張る、届いた指示が「台帳」に載っているか照合する、載っていれば決められたとおりに実行して結果を返す。台帳にないことは、どんなに丁寧に頼まれても実行しません。頭を使う仕事――自然文の依頼を作業手順に翻訳する係(planner)――は将来の別レイヤに置き、実行係はあくまで手足に徹します。
この分離には実利もありました。実行係の依存はRedisクライアントとYAML読み込みだけ。推論エンジンも、データベース接続も、HTTP通信も積んでいません。公開サーバに新しく開ける受信ポートはゼロ。家庭内の司令塔(Coordinator)へ外向きに接続しに行くだけなので、攻撃面が増えないのです。marsにOllamaを入れる必要すらありませんでした。
シェルを渡さない、という物理
「台帳にあるものだけ実行する」を口約束で終わらせないために、コマンドの組み立て方そのものを縛りました。シェル経由の実行(shell=True)は使わず、コマンドは常に引数の配列として固定。台帳に書けるパラメータの穴も「独立した1要素まるごと」だけを許し、文字列の途中に値を埋め込む書き方は台帳の読み込み時点で弾きます。
これが効くのは、セミコロンで第二のコマンドを継ぎ足す古典的な注入が原理的に成立しなくなるからです。実際にテストで safe; touch /tmp/PWNED のような値を流し込み、罠のファイルが生まれないこと、環境変数の $HOME が展開されないことまで確認しました。「注入を検知して防ぐ」のではなく、「注入が起こる場所を最初から作らない」。
ひとつ、実装中に踏んだ小話を。パラメータの穴の記法を最初は {name} の波括弧で書いていたのですが、コンテナ一覧を取る docker ps の書式指定が、まさに {{.Names}} というGoテンプレートの波括弧記法だったのです。台帳の検証器が正規の書式指定を「怪しい穴」と誤認して弾いてしまう。記法を <name> の山括弧に変えて衝突を解消しました。安全のための検証が厳しいほど、正規の記号と喧嘩する――allowlist設計あるあるとして記録しておきます。
実行結果を、頭脳に戻さない
もうひとつの防壁はplan-once / execute-fixed。計画は最初に一度だけ立て、実行中の出力を見て計画を練り直すことはしない、という原則です。
賢いAIエージェントの流儀とは逆行します。普通は「コマンドを打つ→出力を読む→次を考える」のループこそが売りだからです。でもこのループは、サーバのログやコマンド出力に混ぜ込まれた悪意ある文章がAIの次の判断を汚染する、いわゆるprompt injectionの通り道でもあります。公開サーバのログには、世界中からの悪意が日常的に書き込まれている。だから実行出力は人間への報告にだけ使い、計画側には二度と戻さない。ループの便利さより、汚染経路を断つほうを取りました。
「できること」と「していいこと」は別の台帳
安全の台帳は、実は二冊あります。
一冊目はカタログ。「このサーバで何が実行可能か」の定義で、YAMLとして各サーバに同梱します。二冊目は承認ポリシー。「そのうち何が承認なしで実行していいか」の定義で、こちらは司令塔のPostgreSQLで中央管理し、運用しながら書き換えられます。可能性の定義と許可の定義を分けたことで、「apt更新は自分のサーバなんだから確認なしでやってほしい」のような希望は、コードを一行も変えずポリシーの1レコードで表現できるようになりました。
承認の既定値はdeny-by-default。ポリシーに明示的に「承認不要」と登録されたものだけが自動実行され、未登録のものはすべて保留に落ちます。さらにその手前で、破壊的な操作――rm、ディスク初期化、権限変更の類――はそもそもカタログに載せません。承認で守るのではなく、承認の議題にすら上げない。承認フラグが管理するのは「書き込みはするが、失敗しても復旧できる」操作だけです。
もう一点、検証ロジックは実行係と司令塔が同じモジュールを共有しています。別々に実装すると、いつか必ず「司令塔は承認したのに実行係が拒否する」という判定のズレが生まれる。単一の検証器を両側からimportすることで、この不一致を構造ごと消しました。ちなみに使い捨ての検証VMだけは全許可で回します。安全弁を全部外した環境でフルに試し、本番は締める。同じコードで両方を表現できるのも、台帳を分けた恩恵でした。
配管でも一つ、事故を先回り
実装中、キュー設計で危ないところに気づきました。当初は全サーバが同じ一本のキューを見張る素朴な形だったのですが、これだとmars宛ての指示を、たまたま先に取りに来た別のノードが拾ってしまう。宛先別のキュー(tasks:maintenance:<node>)に分けて、指示は必ず名指しで届くようにしました。分散システムの初歩ですが、初歩ほど素朴に書いて踏むものです。
5ミリ秒が「たまたま」を暴いた
さて、ここからが本題の後半です。テストは実行係27・司令塔26・集計係31の計84ケース、すべてグリーン。実機に配備し、marsのディスク使用率と稼働時間を取らせ、結果がNextcloud Talkに届くまでの一気通貫も確認しました。
[maint] mars / node.uptime → done (status=ok exit=0 dur=0.005s)
この通知が着弾した瞬間は、素直に嬉しかった。ところが後からPostgreSQLを覗くと、様子がおかしい。
maintenance_tasks: 「queued(実行待ち)」のまま残留 …… 3件 結果キュー LLEN maintenance:results = 0(結果は回収済み) 集計係のログ: task not found (rowcount=0)
結果は間違いなく回収されている。なのに台帳への「完了」の書き込みだけが空振りしている。
真因は、速さでした。通知にあるとおり、稼働時間の取得はわずか0.005秒で終わります。指示をキューに入れてから、実行係が処理し、集計係が「完了」を書き込みに来るまで、往復およそ5ミリ秒。一方、指示を投入した側のプロセスは、キュー投入のあとで「実行待ち」のレコードを台帳に書く順序になっていました。往復5ミリ秒が、この書き込みを追い越したのです。集計係が「完了」に更新しようとした瞬間、更新すべき行がまだ存在しなかった。
修正は順序の反転です。先に「実行待ち」を記録してから、キューに入れる。こうすれば実行係が指示を拾える時点で行は必ず存在し、空振りは原理的に消えます。「記録がキュー投入に先行すること」を検証する回帰テストも足しました。
怖いのは、最初の通知が成功していたことです。あのときは投入側の書き込みが偶然先に間に合っただけで、仕組みとしては最初から不安定でした。もし最初の3件が全部レースを踏んでいたら、すぐ気づけた。中途半端に成功したせいで、「動いた」という印象だけが先に立った。速いパスほど順序の前提は壊れやすく、「たまたま通った」はグリーンではない。このクラスタで何度も唱えてきた「計測してから信じる」の、新しい変奏です。
おまけ:テストと初稿にも、それぞれ落とし穴
細かい教訓を二つ。テストでは、偽物のRedisからループを脱出させるのに普通の Exception を使ったら、本体の「接続が切れても死なずにリトライし続ける」fail-open設計にその例外ごと飲み込まれ、テストが無限ループしました。障害に強い設計は、テストの制御信号まで握り潰す。脱出用の例外を BaseException 派生にして通常の例外階層の外へ逃がし、解決しました。
もう一つ。台帳の初稿には、確認せずに書いた「ありそうなサービス名」――nginx、mysql――が並んでいました。marsで systemctl list-units を叩いてみると、そのどちらも存在しない(実体はapache2とmariadbでした)。実在するものだけのenum許可リストに差し替えて確定。allowlistは現物から作る。想像で書いたallowlistは、安全でも便利でもない、ただのリストです。
この土台の上に、頭脳を載せる
いまmarsと司令塔には、それぞれ小さなデーモンがsystemdで常駐しています。指示を投げれば、承認判定を通り、名指しのキューを流れ、台帳と照合されて実行され、結果がPostgreSQLに記録されてTalkに一行で届く。まだ入り口は手動で、自然文からの翻訳係は載っていません。
でも順序はこれで正しかったと思っています。allowlistとdeny-by-defaultは、AIの能力を諦めるための柵ではなく、将来もっと賢い頭脳を安心して繋ぐための土台です。翻訳係がどれだけ流暢になっても――たとえいつかフロンティア級のAPIを頭脳に据えても――実行できるのは台帳にあるものだけ。実行結果は頭脳に戻らず、破壊的な操作は議題にすら上がらない。この不変条件があるから、頭脳側は気楽に差し替えられる。
ただし一つだけ、恒久的に採用しないと決めたことがあります。自由文の指示から書き込み操作を、承認なしで自動実行すること。ここだけは、どんなに賢いモデルが来ても開けません。柵は、外すためではなく、その内側で走り回るためにあるのだから。