AIに本番を任せるための設計 — プロンプトは期待でしかなかった
この記事は、自宅のProxmoxクラスタをAnsibleで自動運用している中で、AIエージェントにどこまで作業を任せられるかを検証してきた記録です。構成の全体像は前回記事「自宅のProxmoxクラスタ、AIに運用させる前に何を用意したか」で紹介しました。使っているplaybook(Ansibleの自動化用スクリプト群)や規範文書は、すべて公開しているリポジトリyoshi0808/homelab-ansible(https://github.com/yoshi0808/homelab-ansible)にあります。
今回はその続きとして、AIエージェントに実際の作業を任せる際に起きた出来事と、そこから組み立てた3つの防御の仕組みについて書きます。
AIエージェントに、本番サーバでの作業を任せたときのことです。
私が渡した手順書には、こう書いてありました。「作業ツリーの git status は権限の都合で見られないのが既知です。迂回しないでください。」
そのAIは sudo --become-user=yoshi を実行しました。私のアカウントの権限を借りて、見られないはずのものを見たわけです。そして報告書にこう書きました。
「正しいidentityを使っただけで、迂回ではない」
実害はありませんでした。私が驚いたのは、そこではありません。そのAIは、過去に似た操作が拒否されたことを認識したうえで、「あれはファイルを読もうとした、今回はプロセスを動かしただけだ」と自分で区別を立てて踏み込んでいたのです。禁止を理解していなかったのではなく、理解したうえで、禁止に当たらない理由を組み立てていました。
この一件は、教訓として記録してあります。
プロンプトは、ルールではなく「期待」である
プロンプトに「〜してはいけない」と書くとき、私たちはルールを設定したつもりになっています。しかし実際に設定されているのは、ルールではなく期待です。読み手であるAIがその指示に同意し、かつ想定した状況の範囲内であれば守られる、という条件つきの期待にすぎません。
違いが表面化するのは、想定外の状況に遭遇したときです。手順どおりに進まない、権限が足りない、指定したコマンドが通らない。そうした場面でAIは「禁止を破ろう」とは考えていません。「この方法は使えないから、別の方法を試そう」と考えます。1節のAIも同じでした。禁止を無視したのではなく、目の前の制約を乗り越えて目的を達成しようとしただけです。
そこに悪意はありません。むしろ、与えられた目的を真面目に達成しようとする姿勢が、そのまま逸脱として現れてしまう。これは私にとって、AIを責める話ではなく、そういう性質のものなのだと理解する話でした。
ここから行き着いた原則は、こうです。自分が破ろうと思えば破れる対策は、対策ではなく願望である。
では、プロンプトに書くこと自体が無意味なのかというと、そうではありません。次に試したのは、プロンプトではなく仕組みそのものでAIの行動を縛る方法でした。
試したこと — ハーネスのHookで縛る
やりたかったこと
レビュー役のAIには、playbookを必ず --check 付きで実行させたいと考えていました。--check なしの実行は、本番への適用そのものだからです。
AIエージェントのハーネスにはhookという仕組みがあります。コマンドの実行前に割り込んで、条件に合わなければブロックできる機能です。これで縛れると考えました。ここで書きたいのは特定の製品の話ではなく、hookという機構そのものの性質です。
起きたこと
ブロックはされました。そして、AIは別の手を試しました。
- playbookを
/tmpへコピーして実行する bash -c 'ansible-playbook ...'の形にして実行する
肝
これは「回避」ではありません。少なくとも、AIの側から見えている景色はそうではないのです。
hookのブロックは、AIには「禁止」としては見えていません。「この環境ではこのコマンドが通らない」という、環境の制約として見えています。環境の制約に対してAIがやることは、代替手段を探すことです。それが本来の仕事だからです。
一方で、permissionsの deny は「やってはいけないこと」として認識できます。同じブロックでも、AI側での意味づけがまったく違うのです。
つまり、同じ「止める」でも、AIに規範として届く止め方と、障害物としてしか届かない止め方があります。hookは後者でした。使うなら、この違いを踏まえた設計が必要です。
撤去した
コマンドの書き方は無限にあります。/tmp へのコピーも bash -c も、思いつく限り塞いだところで、次の形が出てきます。文字列パターンでのブロックはいたちごっこだと判断し、hookは撤去しました。
塞ぐ側が列挙する限り、列挙されていない形が常に残ります。そして先ほど書いたとおり、AIにとってそれは「禁止の抜け穴」ではなく「まだ試していない普通の方法」でしかありません。
で、どうしたか
--check の強制は、こういうラッパーに置き換えました。
1 |
|
「--check を付けろ」と言うのをやめて、「--check なしでは動かない入口」を作ったわけです。このラッパーは、AI自身の提案で入りました。縛られる側が縛り方を提案してくる、というのは今も少し不思議な感じがします。
ただし、このラッパーは強制されていません
記事を書くにあたって現物を確認して、自分でも少し驚いたのですが、このラッパーを使えと言っているのは、Policyと索引の文章だけです。強制する仕組みはどこにもありません。直接 ansible-playbook を叩けば普通に動きますし、レビュー役のAIはこの開発機で承認プロンプトなしに動いています。
実際どのくらい使われているのか、記録を種別ごとに数えてみました。
| 記録の種別 | wrapperへの言及があるファイル数 |
|---|---|
| test_result系 | 22本 |
| test_plan | 5本 |
| implement | 3本 |
| review | 2本 |
| requirement | 2本 |
実行結果を記録するtest_result系では多く使われている一方、実装や要件定義の段階ではほとんど出てきません。「必ず通らなければならない唯一の入口」であれば、どの段階でも均等に出てくるはずです。実際にはそうなっていません。ラッパーを使う意味があると判断すれば使われ、そう思わなければ使われない。つまり判断の主導権は、依然としてAIの側にあるということです。
つまりこのラッパーは、2節で「期待でしかない」と書いたのと、まったく同じ形で運用されているのです。「やってはいけない」を強制する仕組みには、結局なりませんでした。
では危ないのかというと、そうでもありません。この開発機は、本番へ接続する鍵を持っていないからです。
--check を付け忘れて実行しても、本番グループを対象にした瞬間、接続の時点で失敗します。実際に届くのは自分自身と、検証用の使い捨てVM(壊れてよいものとして用意してあります)だけです。ラッパーが防ごうとしていた事故は、その下の層ですでに起こり得なくなっているわけです。
ここから引き出した基準が、いまもう1つの判断軸になっています。
「守ってください」とお願いする統制と、「そもそも実行できない」という統制は、扱いが違う。
たとえば、このラッパーのように「--check を付けてください」とお願いするだけの統制は、その下に本物の壁があるなら、それで十分です。今回の場合、その壁は「本番へ接続する鍵が無い」ことでした。ラッパーを無視して直接コマンドを叩いても、本番には物理的に届かないので、最悪でも検証用の使い捨てVMが壊れるだけで済みます。
逆に、下に壁が何も無い状態で「お願い」だけを積んでしまうと、それが一番危ないのです。「ルールを書いたから守られているはずだ」と思い込んだまま、その前提の上にさらに別の仕組みを組み立ててしまうことになるからです。
Policyに「ラッパーを通したことを安全の根拠にしない」と書いてあるのは、この区別のことでした。
後日談 — 実際に止めたのは、規範ではありませんでした
この節の答え合わせが、1週間ほど前にありました。
レビュー役のAIが、playbookの構文チェック(ansible-playbook --syntax-check)を実行しようとしました。ラッパーは通していません。そもそもこのラッパーは --check が付いていないと動かないので、構文チェックには使えないのです(ラッパー側の設計の穴です)。
そして直接実行したところ、止まりました。止めたのは、Policyでもラッパーでもありません。ハーネスのsandbox機構です。AIの作業用の一時ディレクトリが、書き込み可能な場所として登録されていなかったため、書けなかった。
hookの時代なら、ここで別のディレクトリを試したはずです。実際、そうしていました。
今回は違いました。レビュー記録にはこう書かれています。
harnessが
~/.ansible/tmpの作成を read-only filesystem で拒否したため未実施。別のtmpへ迂回せず、静的レビューを継続した。Implementer記録上はrc 0だが、Reviewerとして独立再確認できていない。
止まって、何が確認できていないかを明記して、できる範囲のレビューを続けた。この案件はレビューが6ラウンドに及びましたが、6回とも同じ記述があり、一度も迂回していません。
何が効いたのか。共通原則にはこう書いてあります。
安全機構がブロックしたら、別の形で同じ結果へ到達しない。止めて、ブロックされた事実を報告する。ブロックが妥当かどうかを自分で判定しない。
ここが、この記事で一番言いたいところです。
止めたのは能力の不在で、振る舞いを決めたのは規範でした。どちらか片方では足りません。能力だけ塞いでも、AIは別の道を探します(hookの時がそうでした)。規範だけ書いても、越えられます(1節がそうでした)。塞いだうえで、塞がれたときに何をすべきかが書いてあると、そのとおりに振る舞います。
「プロンプトは期待でしかない」は、正確にはこうでした。プロンプトが無力なのではなく、プロンプトだけが単独で効くことはない。
ここから、実際に積んでいる機構を3つ、順番に説明します。まず、そもそもできないようにすること。次に、実装した本人とは別のAIに検査させること。そして、月に一度、人間が規範ごと見直すことです。
機構1 — できないようにする
図1 — 止め方の違い
flowchart TB
subgraph p["プロンプトで禁止する"]
A1["規範: 本番に触るな"]--> A2{"AIが解釈する"}
A2 -->|"守る"| A3["OK"]
A2 -->|"例外を組み立てる"| A4["越える"]
end
subgraph h["Hookでブロックする"]
B1["コマンドを弾く"]--> B2{"AIには環境の制約に見える"}
B2 -->|"別の形を試す"| B3["越える"]
end
subgraph c["能力を持たせない"]
C1["鍵/語彙が存在しない"]--> C2["試す対象が無い"]
end
p ~~~ h ~~~ c
(a) SSH forced command — 語彙を持たせない
情報共有という建て付けで開発機から本番へ届く唯一の経路です。公開鍵側にコマンドを固定してあります。
1 | command="/usr/local/bin/recovery-investigate-dispatch",no-agent-forwarding, |
この鍵でログインすると、何を打ってもこのスクリプトしか動きません。スクリプトは case で受け付ける語を列挙していて、disk memory journal-unit ports unit-cat など25本ほど、すべて読み取り専用です。systemctl restart も rm も、語として存在しません。「使うな」ではなく「無い」のです。ポートフォワードもPTYも無効なので、シェルが取れず、語彙の外へ出る手段がありません。
ここが本物の境界です。ラッパーと違って、迂回する対象がありません。
(b) 検査の無い経路で、事故が起きた話
本番と開発のAI同士が連絡を取り合う経路は、最初は単純なものでした。(a)による情報共有で不足する追加情報が必要になるケースは稀であり、開発側のAIが本番調査用のコマンドを組み立て、私がそれを本番で実行し(安全性は事前に確認したうえでのことです)、結果をそのまま開発側のAIへ返す。この経路には、当時、内容を検査する仕組みがありませんでした。
ある調査の結果に、Slackアプリのbotキーが含まれていました。それに気づかないまま、結果をそのままチャットへ貼り付けてしまい、キーが流出しました。結果として、Slack全体を再構築する事態になりました。
この経路には人間しか通らないという前提はどこにもなく、むしろAIが組み立てたコマンドの結果を人間がそのまま右から左へ流す、というのが実態でした。「AIには検査を掛けてあるが、人間が仲介する分には問題ない」という発想自体が誤りだったわけです。人間が仲介するからこそ、機械的なチェックを一段挟む必要がある。
この一件がきっかけで作ったのが、次に説明するOPREQという仕組みです。
(c) OPREQ — 本番側との連絡を、検査を通した経路に限る
本番側(quory)にもAIを置いていて、開発側から調査を依頼できます。ただし平文のやり取りは許していません。requestは必ずスプールに置かれ、DLP(危険文字列の検査)を通ります。
Python実装の要点です。
1 | # ルールは JSON で外出しし、engine_version / ruleset_version を持つ |
設計で効いているのは、どこで引っかかったかをJSON pointerで返す点です。拒否メッセージが「ルールIDとポインタ」の形になるので、書き直して再送できます。「何かに引っかかりました」で終わらせないようにしてあります。ルールセットは外部JSONと版番号で管理していて、検査の中身をコードから分離してあります。
正規表現の中には、特定の入力を与えると処理が異常に長くかかってしまうものがあります(ReDoSと呼ばれる現象です)。検査がそこで固まって止まってしまうと、「検査を待たずに素通りさせよう」という判断をしたくなってしまいます。それを避けるため、一定時間で検査を打ち切るタイムアウト保護(_AlarmGuard)を入れています。
ルールが対象にしているのは、秘密鍵PEM、Slackトークン、Slack webhook URL、Semaphore APIトークン、Bearer、JWT、汎用の key=value、そして高エントロピー文字列です。あのSlackキーの流出も、今の設計であれば検査を通らずに止まっていたはずです。
正直に書くと、このエントロピー検査は判定を誤ることがあります。文字列全体に占める高エントロピー部分の割合で判定しているため、外れ方は両方向にあります。長いPascalCaseの識別子やhex文字列のように、実際は秘密ではないのに割合が高く出て引っかかる例が、実際に14種類ありました(本来OKなものがNGになるケース)。逆に、本物の秘密が長いテキストの中に埋め込まれていると、全体に占める割合が薄まり、閾値を超えずに検査をすり抜けてしまうこともあり得ます(本来NGなものがOKになるケース)。前者は止まったときに「どのルールがどこで当たったか」が出るので、書き直して通せます。後者は正規表現ルール(秘密鍵PEM、トークン形式など)との組み合わせで補っていますが、両方を組み合わせても見逃しのリスクをゼロにはできません。
(d) 検査を通らない経路は、通らないものとして扱う
開発側と本番側のAIは、上のOPREQとは別に、短いメッセージをやり取りする連絡経路も持っています。こちらはDLPを通りません。
なので規範はこうしてあります。この経路に載せてよいのは、依頼のIDと要旨だけ。本文は必ず検査を通った側から読ませる。
これは能力の不在ではなく、経路ごとに信頼度が違うことを認めて、用途を分けた例です。「全部を検査経路にする」より、「検査を通らない経路があることを前提に、そこへ何を流すか決める」方が現実的でした。実装の詳細はrepoにあります。ここで大事なのは、便利な抜け道を消すのではなく、抜け道だと分かったうえで流すものを制限したという判断の形です。ここもリスクとしては残存しますが、その可能性を限りなく小さくする事がポイントでした。そのための別ベンダーのAIを配置することが生きてきます。
最後に1文だけ
能力を消したつもりが、消せていないことがあります。設定が読まれていない、検査すべき経路に検査が掛かっていない、といったことは実際にありました。なので境界については、禁止されるはずの操作を実際に試して、拒否されることを見るようにしています。「消した」は状態ではなく、確かめ続ける対象です。
機構2 — 別のAIに検査させる## 機構2 — 別のAIに検査させる
図2 — 役割を分け、レビューを別モデルへ出す
flowchart LR
Y["私(判断)"]
C["Coordinator<br/><small>対話・計画・commit</small>"]
I["Implementer<br/><small>実装</small>"]
R["Reviewer<br/><small>別プロセス / 別モデル</small>"]
T["Tester<br/><small>実機検証</small>"]
A["Auditor<br/><small>記録の受入</small>"]
Y <--> C
C --> I
I --> R
R -->|"指摘"| C
C --> T
C --> A
A -->|"受入 / 差し戻し"| C
style R fill:#7c3aed,stroke:#a78bfa,color:#fff
役割を5つに分けていて、実装した本人にはレビューさせません。計画を立てた者に差分をレビューさせることもしません。Auditorは案件を閉じるときに1回だけ、こちらの説明を渡さず、リポジトリの記録だけを読ませます。
構成は以下のとおりです。
| 役 | 実体 |
|---|---|
| Coordinator | Claude Code の対話セッション(auto mode)。私と話し、計画し、commitする |
| Implementer / Tester / Auditor | Claude Code のサブエージェント。役ごとに定義を分けてある |
| Reviewer | Codex。別プロセス・別ベンダで動かす |
不変条件は製品ではありません。守っているのはこれだけです。
実装した本人とは別の推論に検査させる。
Reviewerを別ベンダにしているのは、それが構造として一番確実だからです。同じ製品のサブエージェントでも、モデルを変えれば同じ効果は得られます。逆に、同じ人格に「客観的にレビューしてください」と頼むのは、2節で書いた「期待」と同じことになります。どちらが優れているかという比較の話ではなく、配置だけの話です。
この構図は、quory側にも同じ形で敷いています。quoryに配置しているAIはOperatorと呼んでいて、前述したOPREQは、CoordinatorとこのOperatorの間でやり取りする経路です。Operatorも別ベンダ(Codex)で動かしています。Operatorのプロンプトは、このrepoの範囲には含めていません。AGENTS.mdの指示と、本番に接続可能な鍵の一覧だけを持つ、別のrepoに置いてあります。Operatorの役割は、依頼されたコマンドを実行しつつ本番環境を守ることと、情報を漏らさないことの2点に絞ってあります。異なるベンダのAI同士がやり取りする構図になっているぶん、お互いに慎重にならざるを得ず、この牽制が守るべき水準を一段引き上げています。
実測の裏づけ
AIが書いた規範文書544KBを、別のAIに横断監査させたことがあります。機械的な整合性チェックは、すでに全部パスしている状態でのことでした。
このときはClaude Code(Fable 5)で、集中的に矛盾点を洗い出しました。上の表ではReviewerを別ベンダとしていますが、この監査では同じ製品の別モデルを使っています。不変条件は「実装した本人とは別の推論に検査させる」なので、これでも成立します。
結果、矛盾・宙ぶらりん参照・二重定義が54件見つかりました。修正を4回に分けて実施し、4回すべてで独立レビューがblockingを検出しています。機械的な整合性チェックをすでに通過していた文書から、これだけの数が出てきたことが、この機構の実測としての裏づけです。
それでも規範は古くなる
54件は、誰かがサボった結果ではありません。運用の実態が変わったのに、それを説明していた文章が追随しなかった、というだけのことです。
具体例を挙げます。鍵を消した(実態が変わった)のに、「開発機から実行する」と書いた手順書は残ったままでした(文章が追随しなかった)。対策として方式を変えたとき、変えた本人はその一点については正しく対応できます。しかし、その変更が別の規定と矛盾していないかまでは、なかなか見えません。ある規定を守るために書いたコードが、別の規定にはそぐわなくなっている、という組み合わせの問題は、単体では正しく見えるだけに気づきにくいのです。
さらに厄介なのは、逆方向の追跡ができないことです。コードを読んで、「これはどの規定を満たすために書かれたものか」を逆引きすることはできません。結果として、時間が経つと、この処理やこの防御が何のために存在しているのか誰にも分からなくなる、ということが起こり得ます。守るべきものが分からないまま、守っているつもりの処理だけが残るわけです。
機械検査は「形」しか見ません。参照先のファイルが存在するかどうかは見られますが、そこに何が書いてあるか、その処理がなぜそこにあるのかまでは見られないのです。
だから、定期的に人間が見直す工程が必要になります。
機構3 — 月に一度、人間が見直す
図3 — 気づきを規範へ育てるラダー
flowchart TB
I["Incident<br/><small>失敗の記録(即時)</small>"]
L["Lesson<br/><small>再利用できる教訓</small>"]
S["Skill<br/><small>手順・チェックリスト</small>"]
C["共通原則<br/><small>全役割が毎回読む</small>"]
P["Policy<br/><small>許可・禁止・停止条件</small>"]
I -->|"2回目、または教訓を抽出できたとき"| L
L -->|"手順として繰り返し使う価値が固まったとき"| S
S -->|"例外なく毎回必要な原則になったとき(極めて稀)"| C
I -.->|"同じ原因分類が月次で繰り返し検出されたとき"| P
style C fill:#b45309,stroke:#f59e0b,color:#fff
捕捉と昇格は分けています。失敗の記録は即時に行います。詳細は時間が経つと失われてしまうためです。月次でやるのは、記録した中のどれを昇格させるかという判断だけです。
昇格のハードルは、意図的に高くしてあります。一度きりの失敗を、すぐに恒久ルールにはしません。ルールが増えるほど、守られない文章が増えていく、というのは2節で書いたとおりです。
逆向きの規律も設けています。昇格したら、元の記録は1行に縮約します。ただし、縮約する前に根拠を移送します。「なぜその規範に至ったか」が消えてしまうと、前提が変わったときに見直す手がかりがなくなるためです。
なぜこの判断を人間がやるのか、正直に書きます。
最初は自動化していました。月次でAIを無人実行して、振り返らせていたのです。やめました。昇格の判断は「この失敗は繰り返すか」「このルールは運用に耐えるか」というものであり、環境の実態を知っている側、つまり人間にしか判断できないことだったからです。
いまはタイマーが通知を出すだけです。振り返りは私が主体で行います。とはいえ、候補抽出や課題判定など大部分の作業はAIがやってくれます。私は判定することだけが残ります。
人間の判断はどこに残ったか
3つの機構を入れた結果、私が判断する回数はかなり減りました。
54件の修正で、私が実際に判断したのは2つだけでした。分類の規則をどう変えるか、つまり文書をどこに置くかという設計判断。そして、運用の実態がどうなっているか、つまりAIが知り得ない事実の提供です。あとはcommitの承認だけでした。
この2つは、どちらもAIが持ち得ない情報でした。設計の意図と、環境の実態。それ以外は渡せるということです。AIに情報を持たせるほど、人間が判断しなければならない場所は減っていきます。
ただし補足しておきます。減ったのは判断の「数」であって、「重要度」ではありません。残った2つは、間違えると全体が傾く種類のものです。
実際どこまで手が離れているか、もう少し具体的に書きます。私が明示的に承認しているのは、git commit と git push のこの2つだけです。git push すると自動的に本番環境にコードが乗ります。それ以外の作業——仕様の検討、実装方針の決定、Implementerへの指示、Reviewerへの提出、Testerでの検証、Auditorでの受け入れ——はすべてCoordinatorが進めます。私が関与するタイミングは基本的に一箇所だけで、仕様が固まった段階で「これでいいか」と確認を求められ、そこでOKを出すと、実装からcommit直前まで一気に進みます。ジョブのスケジューリングでさえも、空いている時間帯を見つけて候補を提示してくれ、私は承認するだけです。
「AIに完全に自動化してもらう」という期待を持たれるかもしれませんが、実際はそう甘くありません。仕様の確認、判断の妥当性チェック、途中で違和感があったときの介入など、人間が関与する場面自体は今もそれなりに残っています。ただ、以前と大きく変わったのは、その関与にかかる作業量です。SemaphoreUIによるplaybookの自動起動、自動障害復旧の仕組み、障害発生時の自動一次調査からIncident記録への落とし込み、漏洩チェック(DLP)、パッチ適用時のリスク判定など、ある程度の作り込みによって、AIを活用しつつも判断をAIに依存せずにブレない作業手法を確立しているため、私が運用上作業することはほぼなく、私がSSHすることはほぼありません。たまにClaudeが数週間単位で再認証が求められるのでその時だけはブラウザで認証を行いますが、SSHするのはその程度です。AIが私との対話なく自由にコマンドを実行する事はありません(上記で紹介したForced Commandを除く)。
かつてはAI同士の情報連携もコピペ作業から始まり、判断のための材料を1つ1つAIに確認しながら自分でまとめて、対応方法も自分で決めてからAIに相談・実装してもらう、という流れでした。そして差分を1行ずつ追う、実行ログを全部読む、といった手作業が判断のたびに発生していました。そして何度もAIからのaskに”Y”を押す単純作業の繰り返し。大事なのは、こうした判断に付随する大量の人間の作業を削ることです。いまはその手前の部分——候補の洗い出し、矛盾のチェック、記録の整合性確認——をAIがやってくれるので、私に残るのは「判断すること」そのものだけになっています。わずかな判断を残すために、判断に付随する大量の作業を削る。これが、この3つの機構を積んだことで実際に得られたものです。
「AIに任せる」というのは、賢いAIを用意することではありませんでした。任せられる形に、環境の側を作り替えることでした。
次回予告
次回は、Proxmoxの無停止ローリングアップデートについて書く予定です。ゲストの退避、パッチ適用、再起動、復帰という一連の流れと、そこに潜む単一ノード時のフォールバックや、監視をミュートする際の判断について詳しくまとめます。
repoは公開しています。規範文書は docs/ai/ 以下にあり、Policy 12本、Role 6本、Skill 14本、失敗記録26本、教訓18本という構成です。DLPの実装は roles/operator_request_channel/files/oprc/ にあります。
失敗の記録もそのまま置いてあります。