はじめに
AIハーネスにおけるセキュリティを考えて、実装してみました。
セキュリティにおいて、特許とかふざけすぎてるので特許取られる前に金払わんでも使えるようにオープンにしちゃえという思想です。
プロンプトインジェクションを完全に防ぐことは不可能なので、インジェクションがあったとしてもポリシーベースのアクセス制御を行えば、仮にOSを破壊せよという指示があったとしても、ハーネスが生成した子プロセスからは読み取れないし書き込めないので破壊は防げるはずです。
概要
AIハーネスにおける強制アクセス制御をWindows(AppContainerとWPF)で実装してみました。
ネットワーク(ドメインベース制御)・ファイルシステムへの強制アクセス制御と、ファイルシステムオーバーレイによる遅延書き込み(CoW)の実装を行っています。
OSSの界隈ではたぶん誰もやっていないので、おそらく論文以外では初の試みだと思います。
(ソースは今後公開予定)
設計思想
「子プロセスが約束を守ってくれること」を前提にしない、という点を重視しました。
LLMが発行するコマンドは、シェルやビルドツール、その孫プロセス(npm installのpostinstallとか、cargo buildのbuild scriptとか、gitのhookとか)まで巻き込みます。これらはOSのAPIを直接叩けるので、ハーネス側が「行儀よく振る舞ってね」とお願いしたところで意味がありません。
「悪性コードは約束を守らない」という前提で設計する必要があります。
なのでセキュリティ境界は必ずOSカーネルが強制するものにする、というのが大原則です。
ユーザーモードのAPIフックだけで「ここから先はダメです」を実現しようとすると、フック自体を無効化されたり、フックしていないAPIを叩かれたりで簡単に迂回されてしまいます。
なので、フックはセキュリティ境界としては使いません。使うのはあくまで利便性のためです(後述のCoWモードで説明します)。
もう一つの思想は、「起動そのものを止める」のではなく「起動しても使える機能を減らす」というアプローチです。真に「怪しい実行ファイルは起動させない」を実現しようとすると、WDACやAppLocker、minifilterドライバのようなカーネルレベルの仕組みが必要になり、これは自作ハーネスのスコープを超えます。
そこで、起動は許すものの、読める場所・書ける場所・繋がれる先をあらかじめ絞っておくことで、仮に悪性コードが動いても被害範囲を小さくする、という現実的なラインを狙いました。
あとは、ハーネス本体は非管理者権限のまま動かすことも大切ですね。
LLMのループやツール呼び出しのパースなど、攻撃面が大きいプロセスに管理者権限を持たせたくありません。
管理者権限が必要な操作(ACL付与やネットワークフィルタの設置など)は、専用の小さい特権分離ヘルパープロセスに切り出しています。
いわゆる権限分離、最小権限制御の観点です。
攻撃可能面は時間とコード量で決まるためハーネスから分離しています。
サンドボックスの全体像(Tier解説)
run_shellで子プロセスを実行するとき、隔離の強さが違う複数のTierを用意していて、環境に応じて自動的に選ばれます(フラグで明示的に下げることもできます)。
- Tier0(保険): 隔離なし。cwdの拘束と環境変数のstripくらいは行いますが、範囲外書込・読取・ネットワークはOS境界としては守りません。何も使えないときの最後の保険です。
- Tier1(Restricted Token + Low Integrity): Windowsの制限トークンと低整合性レベルで、範囲外への書込は拒否できます。ただし低ILは中ILのファイルをreadできてしまうため、読取の機密性は守れません。ネットワークも素通りです。
- Tier2a(AppContainer): 今回の本題です。package SIDとcapabilityでOSに直接ファイル・ネットワークのアクセス可否を判定させます。Windowsの既定Tierにしています。
- Tier2b(Linux bwrap): Linuxでは同じ思想をuser/mount/networkのnamespaceとoverlayで実現します。bubblewrapを使います。
- Tier3(Hyper-V + Incus): 一番強い境界です。ホスト側にHyper-Vの外層VMを立て、その中のIncusコンテナで実行します。起動コストが高いので明示オプトインにしています。
Windows環境ではTier2aをフラグなしで既定プローブし、使えなければ起動そのものを拒否するようにしています(黙ってTier1やTier0に落ちて「隔離してるつもり」になるのが一番怖いためです)。
Tier1まで落とすには --tier1 を明示する必要があります。
Windowsで既定のTier2a
AppContainerを選んだ理由
Windowsで非管理者のままプロセスを隔離する手段はいくつか検討しました。
- Restricted Token + Low Integrity(Tier1)だけだと、上で書いた通り読取の機密性とネットワーク出口を守れません
-
bindflt(Bind Filter)は、あるフォルダを別のフォルダにまるごとすり替えて見せる機能で、読み書きをまとめてリダイレクトはできますが、「読みは元の場所、書きだけ別の場所」という使い分け(Copy-on-Write)はできません -
wcifs(Windows Container Isolation FS)はその使い分けができますが、対象プロセスが本物のWindows Containerのsilo文脈にいることが前提になっており、単にプロセスを起動するだけでは使えません。しかも管理者権限が必要です
結局、非管理者のまま package SID•capability SID を持たせてOS側にアクセス可否を判定させられる AppContainer を軸に据えることにしました。
ファイルシステム制御
AppContainerは既定でほとんどの外部ファイルを読めない(deny-by-default)ので、ファイルシステム制御は「必要な場所だけ穴を開ける」allowlist方式になります。
-
fs.read: 読取のみ許可 -
fs.read_exec: 読取・実行を許可(外部ツールの置き場所など) -
fs.read_write: 読取・書込を許可(キャッシュやツールチェーンの作業ディレクトリなど)
これらはハーネス起動前のpreflightで、package SIDに対してACL(アクセス制御リスト)のACE(アクセス許可エントリ)を追加していく形で実現しています。ユーザー所有のパスはハーネス自身が付与しますが、Program Filesのようなシステム保護パスは非管理者トークンにWRITE_DACが無いため、特権分離ヘルパーにまとめて委譲します。
また、.harness(ハーネス自身の設定ディレクトリ)は、モードに関わらず常にpackage SIDのACEを剥奪しています。
LLMが「.harness/settings.jsonを書き換えてallowlistを緩めて」のような指示を(インジェクション経由で)実行しようとしても、子プロセス側からはそもそもOSレベルで書き込めません。
ネットワーク制御
ネットワークは複数の層を重ねています。
-
AppContainer capability:
internetClientcapabilityを持たない子プロセスは、直接socketを開こうとしてもOSに拒否されます。これが一番の基盤です -
アプリ単位のallowlist: 信頼したコマンド(例:
git)だけをこのcapabilityを持つクラスに入れます。ただしcapabilityはプロセスツリーに継承されるため、許可するコマンドはできるだけ狭く絞ります -
協調プロキシ:
HTTP_PROXY等を読むツールに対してドメイン単位の許可・拒否と監査を行います。ただしraw socketで直接出ていくプロセスには効きません - WFP(Windows Filtering Platform): package SIDを条件にしたパケットフィルタで、raw socketの抜け道にも対応します
まとめると、プロキシ経由をさせたいが、迂回されることもあるので迂回されるケースはWFPで拒否すればいい。
出口を一つに絞り協調的なプロキシを事実上強制するという考え方です。
上記の通り、既定はもちろん拒否であり、何も許可していなければ、子プロセスは外に一切繋がりません。
Copy-on-Writeモード(--cow)
既定のTier2aでは、workspaceのACLは読み書き両方許可したままにしています。
そのため、ハーネスのツール書き込みはCoWが既定です。
つまり、ワークスペースへの書き込みは事後承認がない限りそのまま上書きはできない様になっています。
一方で、シェルコマンドを実行するrun_shellコマンドは子プロセスを生成するので、協調的な制御は効きません。
そのためrun_shellが生成する子プロセスの書込は「ワークスペースまたは許可したパス以外には書けない」という意味では守られていますが、ハーネス自身のファイル編集ツール(Edit/Writeに相当するもの)が通るレビュー•undoの仕組みには乗りません。
コマンド経由の書込だけ「見えない」というのは気持ち悪いので、オプトインでこれを解消する--cowモードを作りました。
やっていることはシンプルで、次の流れになります。
- workspace本体のACLを読取専用(RO)に変更する
- workspace外に用意した「upper」領域(実体は
%LOCALAPPDATA%配下)にだけ読み書き許可を与える - 子プロセスを
CREATE_SUSPENDED(一時停止状態)で起動し、Job Objectに割り当てたあと、Redirector DLLというものを対象プロセスに注入する - DLLの初期化が終わった合図(named pipe経由)を待ってから、
ResumeThreadでプロセスを再開する
このRedirector DLLがntdllのNtCreateFile/NtOpenFileをフック(retourというRustのinline hookクレートを使用)していて、workspace配下への書込を検知すると、対象ファイルをupper側にコピーしてからそちらへ書込先をすり替えます。いわゆるCopy-on-Writeの「Copy」の部分を、このフックが担っています。
ここが、設計思想のところで書いた原則の実践編になります。
このDLLフックは、あくまで「利便性」のためだけに存在していて、「安全性」の根拠には一切していません。
もしDLL注入が失敗しても、何らかの理由でフックが無効化・回避されても、workspace本体のACLはRO(読取専用)のままなので、書込を試みればOSレベルでACCESS_DENIEDになります。つまりフックが動いていようがいまいが、workspaceが物理的に壊れることはありません(fail-closed)。
フックが担っているのは「壊れないようにする」ことではなく、「壊さずにcopy-on-writeとして書けるようにする」という透過性の部分だけです。
境界はACL、透過性はフックという整理にして、フックは安全性の境界にしません。
この切り分けは結構大切で、「DLLインジェクションに失敗してもfail-closedで安全だから、即座二子プロセスを落として警告を出せばいい」と設計すればよいだけになります。
まとめ
ただし、ポリシーで許可したファイルは読めてしまうので、「.sshの中身を読んで」のような指示を受け取ったAIがいた場合、.sshをfs.readに入れてしまっていれば読めてしまいます。
すべては強制アクセス制御ポリシー次第になります。
つまりこの仕組みは「プロンプトインジェクションを検知する」とか「悪性の指示を賢く見抜く」という話では一切なく、「たとえ悪性の指示にAIが従ってしまっても、OSレベルで許可していないことはできない」という、確実なラインを引くための仕組みです。