WebAssemblyは、サンドボックス(外に手が届かない隔離された実行場所)として設計されています。線形メモリの外には触れられず、ファイルもネットワークも見えません。安全ではありますが、そのままでは計算しかできません。設定ファイルを読むことも、時刻を取ることもできません。
この「何もできない」状態から、必要な分だけ外の世界とつなぐための仕様がWASI(WebAssembly System Interface)です。2019年に最初の案が公開され、いまも改訂が続いています。そしてWASIの設計は、単なるシステムコールの寄せ集めではありません。権限の渡し方そのものに踏み込んでいます。
WASMとWASIは、層が違います
まず位置関係を整理しておきます。WebAssemblyの仕様そのものには、外の世界に触れる手段が一切定義されていません。数値を計算し、線形メモリを読み書きし、ホストが渡した関数を呼ぶ。それだけです。2019年12月にW3Cの勧告となったWebAssembly 1.0の仕様書を最後まで読んでも、ファイルを開く命令も通信する命令も出てきません。
そのためブラウザーでWASMを使うときは、JavaScript側が必要な関数を用意してモジュールに渡します。「ホストが渡した関数」の中身をJavaScriptで書くことになります。
ブラウザーの外でWASMを動かすなら、この「ホストが渡す関数」に共通の取り決めが要ります。ランタイムごとにファイルの読み方が違えば、同じバイナリがどこでも動くという利点が失われるためです。その取り決めがWASIです。WASMが実行の仕様で、WASIがその外側との境界の仕様、という層になっています。
ambient authorityという前提
通常のOSでプロセスを起動すると、そのプロセスはユーザーの権限を丸ごと引き継ぎます。ホームディレクトリの中身は読めますし、/etcも見えますし、外部への通信もできます。プログラムがopen("/etc/passwd")と書けば、それが通ります。
この、プログラムが名前を書くだけで到達できてしまう権限をambient authority(周囲権限)と呼びます。プログラムに与えたものではなく、環境に最初から漂っている権限です。
問題は、プログラムが実際に何にアクセスするのかが、コードを全部読むまで分からないことです。依存ライブラリの奥深くでfs.readFileSyncが呼ばれていても、外からは見えません。「このプログラムはどのファイルを読むか」に答えるには、静的解析か、実行して観察するしかありません。
Capability-based security:渡したものしか使えません
これに対する考え方がcapability-based security(ケーパビリティベースのセキュリティ)です。
原則は単純です。リソースへのアクセスは、明示的に渡されたハンドルを通してのみ行えます。 プログラムは名前を書いて何かに到達することができません。呼び出し側から渡された「開いた状態のもの」だけを使えます。
ファイルディスクリプタ(開いたファイルを指す番号)が分かりやすい例になります。すでに開かれたものを渡されたプログラムは、それが指すものにしかアクセスできません。パス名から新しくファイルを開くことはできません。渡していない権限は、行使する手段がありません。
WASIはこの原則を採用しています。WASMモジュールは、ホストから渡されたcapabilityの範囲でのみ外の世界に触れます。
preopen:開いたディレクトリしか見えません
WASIの実装で、この原則が最もはっきり見えるのがpreopenです。
ホストは、WASMモジュールを起動する前に「このディレクトリを使ってよい」と指定します。wasmtimeなら--dirフラグで渡します。
wasmtime --dir=./data app.wasm
モジュールから見えるのは、渡された./dataの中だけです。../で上に抜けようとしても、/etc/passwdを絶対パスで開こうとしても通りません。パスの解決がホスト側でpreopenされたディレクトリを起点に行われるため、その外は名前として存在しないからです。
注目すべきは、これが実行時の設定で決まることです。モジュール側のコードは変えなくて構いません。渡すディレクトリを変えれば権限が変わります。「このプログラムは何を読むか」の答えが、コードではなく起動コマンドに書いてあります。監査する側にとって、これは大きな違いになります。
ネットワークも同じ考え方で、ソケットを開く権限はホストが明示的に渡します。何も渡さなければ、モジュールの中に通信のコードがあっても、それは動きません。
出所の分からないコードを動かせます
この性質が効くのは、自分で書いていないコードを動かすときです。
プラグイン機構を考えてみます。利用者が書いたスクリプトを自分のサーバーで実行したい。従来なら、コンテナで隔離するか、言語レベルのサンドボックスを作るか、そもそも諦めるかでした。コンテナは起動が重く、言語レベルのサンドボックスは抜け道が絶えません。
WASIであれば、preopenを渡さずに起動すれば済みます。そのモジュールはファイルもネットワークも触れません。計算はできますが、外に何も出せません。安全性がホスト側の設定で保証されるので、モジュールの中身を信頼する必要がありません。
EnvoyのWASMフィルタや、各種のプラグインシステムがWASMを採用しているのは、この性質のためです。配布されたバイナリを、中身を検証せずに実行できます。
Preview 1とPreview 2
WASIには大きな段階が二つあります。
長く使われてきたのがPreview 1(wasi_snapshot_preview1)です。POSIXに近い関数の集合として定義されていて、ファイル、時刻、乱数、環境変数、標準入出力といった基本的なものが揃っています。多くのツールチェーンがいまも出力先として想定しています。
その後に来たのがPreview 2で、2024年に0.2.0として固まりました。こちらは作り直しに近いものです。Component Modelという仕組みの上に再構築され、インターフェースをWIT(WASM Interface Type)という言語で記述し、モジュール同士が型のある境界でつながるようになりました。wasi:filesystem wasi:sockets wasi:httpのように、機能ごとに分かれたインターフェースとして定義されています。
実務上の意味は、モジュールが必要とする権限がインターフェースとして明示されることです。wasi:socketsをimportしていないモジュールは、通信できないと型のレベルで分かります。中身を読まなくても、必要な権限の一覧が取れます。
まだ移行期にあります。ツールチェーンによって対応状況が違うので、使う前に対象のランタイムがどちらを想定しているかは確認したほうが安全です。
限界
万能ではありません。注意する点が五つあります。
ホスト側の設定は運用の約束になります。 preopenに/を渡してしまえば、capabilityの利点は消えます。仕組みが保証するのは「渡したものしか使えない」までで、「何を渡すか」は人が決めます。
計算資源の制限は別問題です。 無限ループやメモリの使いすぎは、capabilityでは防げません。ランタイム側の実行時間制限や、メモリ上限の設定が別に要ります。
サイドチャネルは範囲外です。 タイミングを使った情報の推測のような攻撃は、この設計が対象にしていません。
移植は無料ではありません。 既存のコードをWASIに載せるとき、open()でパスを直接指定している箇所は書き換えが要ります。preopenされたディレクトリを起点にした相対パスに直す、という作業が発生します。ambient authorityを前提に書かれたコードほど、この手間は大きくなります。
エコシステムの成熟度に差があります。 RustやC/C++ はターゲットとして安定していますが、言語によっては対応が発展途上です。特にPreview 2への対応状況はまちまちで、採用前に確認が要ります。
まとめ
WASIが持ち込んだのは、システムコールの標準化そのものよりも、権限をコードの外に出したことの方が大きな変化でした。
プログラムが名前を書いて到達するのではなく、呼び出し側が渡したものだけを使う。その結果、「このプログラムは何にアクセスするか」という問いに、コードを読まずに答えられるようになります。起動時の設定を見れば分かります。
出所の分からないコードを走らせる場面が増えるほど、この性質の価値は上がっていきます。自社の仕組みを見直す際は、AIや外部の部品に渡している権限が、コードの中ではなく起動時の設定として一覧できる状態かどうかを確かめてみてください。一覧できないのであれば、実際に何へ触れているかを答えるのに、毎回コードを読む作業が必要になります。