engineering

自律エージェントのためのSeccomp-bpf

うちのエージェントが消してはならないものを初めて消したのは、火曜日の21時47分だった。時刻まで覚えているのは、私が画面を消そうとした、まさにその瞬間にディスクフル警告が飛び込んできたからだ。エージェントは権限昇格CTFについての推論連鎖の途中で、「一時アーティファクトの掃除」をするのに最適な方法はfind / -name "*.tmp" -deleteを実行することだ、と判断していた。コンテナは隔離されていたから致命的なものには触れなかったが、ファジング・ランナー用に使っていたベースイメージを使い物にならなくしてしまった。翌朝、再構築する羽目になった。

その晩、私はノートに一文を書き留めた。今もそれはモニターに貼り付いている。「LLMは『取り返しがつかない』が何を意味するかを理解していない」。そこから、我々の実行エンジンGwaihir CLIが今日使っているサンドボックスモデルが生まれた。そして調整に最も骨が折れたピースが、seccomp-bpfだった。

なぜseccomp-bpfなのか、なぜ他ではないのか

もっともな疑問はこうだ。Docker、gVisor、Firecracker、Kataがあるのに、なぜ手作業でBPFフィルタを書こうなどとするのか。短い答えは、いずれもオフェンシブなツールを連射するエージェントのワークフローには合わなかった、というものだ。

runcモードのDockerはホストとカーネルを共有し、デフォルトのseccompプロファイルは300以上のsyscallを通す。Webサービスにはまあ妥当だが、実行時にptracekexec_loadを呼ぶか決める何かに対しては妥当ではない。Firecracker(Agache et al., 2020)はエージェントごとにmicroVMをくれたが、nmap -sSを一度呼ぶごとにVMを起動するコストは我々のレイテンシ予算を破壊した。125 msに対して、seccomp適用済みのfork+execなら4 msだ。

gVisorは有力候補だった。Sentryがsyscallをユーザ空間で横取りし、カーネル表面を劇的に縮める。だがYoung et al.のHotCloud 19の研究がすでに測定していたことを、後に我々の手元のベンチでも確認することになった。ファイルオープンが216倍遅く、syscallが2.2倍遅い。エージェントがffufを200スレッドで某エンドポイントに浴びせるとき、その代償は明確に現れる。

一方、seccomp-bpfはほぼタダだ。フィルタはBPFにコンパイルされ、プロセスディスクリプタに住み、syscall当たりのコストは数十ナノ秒のオーダーだ。そして最も重要なのは、ルールごとに我々が制御できることだ。カーネルのドキュメントが正しく言うように、「これ自体はサンドボックスではない。サンドボックス開発者が使うための道具だ」[1]。我々が欲しかったのはまさにそれだった。

ツールごとの最小プロファイル

モデルはツールごとであり、エージェントごとではない。Gwaihirがnmapを実行すると決めても、sqlmapのプロファイルは読み込まない。エージェントが呼べる各バイナリは、それぞれ独自の.scprofileファイルを持ち、strace -cで取ったトレースで観測された最小限のsyscall集合を、何度かの実利用パスで広げたものを記述している。

構築プロセスは退屈であり、だからこそ機能する。ツールをラボのターゲットに向けて走らせながらperf traceを回し、syscallのリストを集め、ドキュメントと突き合わせ、出てきたとしても明らかに危険なものは捨てる(たとえばnmapは決してunsharemountを必要としないはずだ)。残ったものがベースラインだ。

典型的なTCP SYNスキャンでのnmapなら、リストはA4一枚に収まる。sqlmapはもっと長い。Pythonインタプリタがもっと多くを要求するからだ。Goで書かれたffufには驚きがある。Goランタイムはrt_sigactionmmapを産業規模で呼び、どれか一つを忘れると、明確な診断もなくプロセスがハングする。

Sentinelとの交渉プロトコル

ここからがエージェントにとって面白くなる部分だ。静的プロファイルだけでは足りない。LLMが実行の途中で「ある発見を補強するためにDNSを引きたい」と決めれば、それはsocket(AF_NETLINK, ...)を呼ぶことになるが、それはnmapのプロファイルには無い。これ以上の仕組みがなければ、プロセスはSIGSYSを受けて死ぬ。

我々がしたのは、子プロセスとSentinelと呼ぶスーパーバイザの間に、seccomp通知チャネル(SECCOMP_RET_USER_NOTIF、カーネル5.0[2]以降利用可能)を据えることだった。フィルタが「交渉可能」とマークされたsyscallに出会うと、プロセスを停止しリクエストをSentinelへ送る。Sentinelは宣言的ポリシーに照らして評価し、必要に応じて「10.10.0.0/8へのconnect()は現在のタスクで正当化されるか?」というような短いプロンプトでモデルに問い合わせ、続行か中止を返す。

AgentBound(Securing AI Agent Execution, 2025)に似たパターンだが、MCPではなくカーネルレベルだ。重要な違いは、エージェントがSentinelを迂回できないことだ。BPFフィルタはexecveの前にNO_NEW_PRIVS付きでロードされるため、悪意あるバイナリですらそれを取り消せない。

実際のフィルタ

これは我々がnmapに使っているプロファイルの簡略化された抜粋で、Rust上のlibseccompで書かれている(Gwaihirランタイムは13.2 MBで、ロジックはRust、トレーシングのホットパスはZig、という混成で作られている)。

use libseccomp::*;

fn build_nmap_profile() -> Result {
    // Acción por defecto: SIGSYS y traza al supervisor.
    let mut ctx = ScmpFilterContext::new(ScmpAction::Trap)?;

    // I/O básico
    for sc in &["read", "write", "close", "fstat", "lseek",
                "openat", "pread64", "pwrite64"] {
        ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name(sc)?)?;
    }

    // Memoria
    for sc in &["mmap", "munmap", "mprotect", "brk", "madvise"] {
        ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name(sc)?)?;
    }

    // Red: solo lo que nmap necesita para SYN scan
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("socket")?)?;
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("setsockopt")?)?;
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("sendto")?)?;
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("recvfrom")?)?;

    // Negociables: el agente puede pedirlas vía Sentinel.
    ctx.add_rule(ScmpAction::Notify, ScmpSyscall::from_name("connect")?)?;
    ctx.add_rule(ScmpAction::Notify, ScmpSyscall::from_name("execve")?)?;

    // Prohibidas explícitamente.
    for sc in &["ptrace", "kexec_load", "init_module",
                "delete_module", "reboot", "mount", "unshare"] {
        ctx.add_rule(ScmpAction::KillProcess, ScmpSyscall::from_name(sc)?)?;
    }

    ctx.set_filter_attr(ScmpFilterAttr::CtlNnp, 1)?;
    Ok(ctx)
}

重要なディテールが3つある。第一に、デフォルトの動作はSCMP_ACT_TRAPであり、SCMP_ACT_KILLではない。理由は診断のためだ。SIGSYSハンドラがプロセスが死ぬ前にsyscallを捕らえ、ログに残してくれることを望んでいる。第二に、交渉可能なsyscallはALLOWではなくSCMP_ACT_NOTIFYを使う。それがSentinelとのチャネルを開く鍵だ。第三に、禁止されているsyscallはデフォルトですでにカバーされていても明示的にKILL_PROCESSを付ける。将来の監査でその判断がはっきり読み取れるようにしたいからだ。

痛い目に遭って学んだトレードオフ

第一は移植性だ。syscall番号はアーキテクチャ間(x86_64、aarch64、riscv64)で変わる。libseccompがかなり上手に抽象化してくれるが、エッジケースはある。32ビットで多重化されたsocketcall、x86にしか存在しないarch_prctl、新しいカーネルで現れたstatx。我々はアーキテクチャごとのテストマトリクスを維持している。

第二はデバッグだ。プロファイルがリストに無いsyscallのせいでプロセスを殺すとき、見えるメッセージはBad system call (core dumped)だけだ。それ以上は何もない。我々はSIGSYSsiginfo_tをパースし、syscall番号を報告し、テーブルと突き合わせ、可読な何かを出すラッパを書く羽目になった。coreダンプの代わりに「statx blocked by profile nmap.scprofile, suggest adding to baseline」を初めて見たとき、安堵で泣きそうになった。

第三は誤検出だ。libcが更新されてcloneの代わりにclone3を使い始めると、すべてのプロファイルが一斉に壊れる。glibc 2.34がベースイメージの一つに入ったときにそれを学んだ。今は週次のジョブでベースラインを再生成し、差分が怪しいほど大きければ警告するようになっている。

第四、そして最も意外だったのは、非常に大きなフィルタのオーバーヘッドだ。BPFは各syscallで実行されるので、何百もの規則が連なるフィルタは効いてくる。180規則のプロファイルではsyscall集約型のワークロードで3〜4%のペナルティを測定し、40規則のもので0.8%だった。頻出するsyscallが先に評価されるよう規則を並べ替えると、その時間の大半を取り戻せた。

もっと早く知っていれば時間を節約できたこと

3つある。第一に、プロファイル開発中はTRAPではなくSCMP_ACT_LOGから始めよ。すべてを記録し、本物のワークロードでツールを丸一日走らせ、それからリストを構築する。我々は逆をやって、クラッシュを追いかけて何週間も失った。

第二に、ユーザ通知(USER_NOTIF)こそが、seccompをエージェントにとって有用なものに変えるものだ。それが無ければ、静的プロファイルはコルセットでしかない。それがあれば、特権分離を犠牲にせずに動的ポリシーが手に入る。

第三に、初日から実コストを測れ。公開されているベンチマークは有用だが、あなたのワークロードは奇妙だ。短いツールを毎秒50回呼ぶエージェントは、長時間稼働するサーバとはまったく異なるコスト特性を持つ。

フィルタはすべてから守ってくれはしない。愚かさから守ってくれる。そして、LLMを野放しにしたとき、その間違いの90%は愚かさだと判明する。

今日、Gwaihirはモデルが提案するエクスプロイトを走らせるが、我々の誰一人として大惨事を待ち構えて画面を見つめてはいない。モデルを信頼しているからではない。フィルタを信頼しているからだ。火曜日の21時47分の後では、これは人生を変えるほどの違いだ。

参考文献

  1. Linux Kernel Documentation (2024). Seccomp BPF (SECure COMPuting with filters).
  2. Drysdale, M. y Corbet, J. (2024). seccomp(2) - Linux manual page, man7.org.
  3. Young, E. et al. (2019). The True Cost of Containing: A gVisor Case Study. USENIX HotCloud 19.
  4. Agache, A. et al. (2020). Firecracker: Lightweight Virtualization for Serverless Applications. USENIX NSDI 20.
  5. seccomp/libseccomp (2024). libseccomp: Enhanced Linux seccomp library.