Legionの「専門家エージェント」はOpenCodeで実現されていて、そのOpenCodeはサンドボックスの中で動いています。

プロジェクトLegion

「Claw系(なんとかClawの類)」は色々な危険を孕んでいると言われたため、フォロワー達はだいたいサンドボックスの中で動くようになっています。 これはLegionも同じです。 今回はこれの実装についてメモしておきます。

概要

簡単に言ってしまえば、

Bubblewrap

を使っています。

隔離環境と言うとVM(Dockerとか)を使うという話になりがちですが、今回のようなお手軽に隔離環境が作れればいいという話であれば、Bubblewrapで十分だろうと思います。

Bubblewrapとは

詳しい説明は公式サイトとか見てもらうといいので、詳しいことはそっちに譲るとして、ごく簡単なまとめをします。

  • ユーザー権限で動く隔離環境
  • 各種リソースが自分に許されたものしか見えない
  • 敢えて副作用を保存しない限り、終了後は全てが消えてなくなる

という隔離環境を作るためのコマンドです。

Dockerとかとの大きな違いは、「コンテナイメージ」を持たないということです。 バイナリとか環境とかは、「ホスト環境のうち許されたものが見える」という設定をします。 今回のように「隔離できれば十分」な用途であれば、DockerやPodmanを使うよりも簡単にできます。

コード

今はこんな感じで使っています。

import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { parse } from 'dotenv';

const persona = process.argv[2];

if (!persona) {
  console.error('persona name required');
  process.exit(1);
}

const dotEnv = parse(readFileSync('.env'));

const allowedPrefixes = [
  'OPENAI_',
  'GOOGLE_',
  'GEMINI_',
  'ANTHROPIC_',
  'LEGION_',
  'OPENCODE_',
  'NODE_',
  'WORKSPACE',
  'CONFIG_DIR'
];

const pickEnv = (source) =>
  Object.fromEntries(
    Object.entries(source).filter(([key]) =>
      allowedPrefixes.some((prefix) => key.startsWith(prefix))
    )
  );

const daemonEnv = {
  PATH: '/usr/bin:/bin',
  HOME: '/',
  NODE_ENV: dotEnv.NODE_ENV ?? process.env.NODE_ENV ?? 'production',

  LEGION_CONFIG_DIR: '/config',
  LEGION_ROLES_DIR: '/roles',
  LEGION_WORKERS_DIR: '/workers',
  LEGION_WORKSPACE_DIR: '/workspace',
  LEGION_LOG_DIR: '/logs',

  ...pickEnv(dotEnv),
};

const args = [
  '--die-with-parent',
  '--share-net',
  '--unshare-pid',
  '--unshare-ipc',

  '--ro-bind', '/usr', '/usr',
  '--ro-bind', '/bin', '/bin',
  '--ro-bind', '/lib', '/lib',

  '--dir', '/etc',
  '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf',
  '--ro-bind-try', '/etc/hosts', '/etc/hosts',
  '--ro-bind-try', '/etc/nsswitch.conf', '/etc/nsswitch.conf',
  '--ro-bind-try', '/etc/gai.conf', '/etc/gai.conf',
  '--ro-bind-try', '/etc/services', '/etc/services',
  '--ro-bind', '/etc/ssl', '/etc/ssl',

  '--ro-bind', './src', '/src',
  '--ro-bind', './node_modules', '/node_modules',
  '--ro-bind', './config', '/config',
  '--ro-bind', './roles', '/roles',

  '--bind', './workers', '/workers',
  '--bind', './workspace', '/workspace',
  '--bind', './logs', '/logs',

  '--proc', '/proc',
  '--dev', '/dev',
  '--tmpfs', '/tmp',
  '--chdir', '/',

  '/usr/bin/node',
  '/src/cli/index.js',
  '--debug',
  'llm',
  '--watch',
  '--persona',
  persona,
];

const child = spawn('bwrap', args, {
  stdio: 'inherit',
  env: daemonEnv,
});

child.on('exit', (code, signal) => {
  if (signal) {
    process.kill(process.pid, signal);
    return;
  }

  process.exit(code ?? 1);
});

この中の

  '/usr/bin/node',
  '/src/cli/index.js',
  '--debug',
  'llm',
  '--watch',
  '--persona',
  persona,

が実際のコマンドの起動のためのもので、その前あたりに書かれているのがBubblewrapの設定です。 OpenCodeを使った「専門家エージェント」は、/src/cli/index.jsから起動しています。

この例では、「起動させたOpenCodeに.envを見せない」ために、起動コマンドで.envを読んで環境変数として渡すようにしています。 もちろんこの隔離環境からは.envは見えません。 これは.envの中に書かれがちのクレデンシャルの類をハーネスに見せないようにする工夫です。

--bindは更新可能な状態でファイルやディレクトリを見せるために、--ro-bindは読取専用で見せるためです。 ここを上手く運用することで、「汚して欲しくないもの」と「結果を書いて欲しいもの」を区別することができるわけです。

結果

たったこれだけのことですが、ハーネスという「じゃじゃ馬」が隔離環境の中に押し込めるというのは、セキュリティ的には安心です。 もちろん動作環境は見せなければなりませんが、OpenCodeにありがちの「あるものは見れるしいじれる」みたいなお行儀の悪い子には、最低でもこれくらいはしておく必要があります。

Bubblewrapはいわゆるコンテナとは違いますから、起動/停止のオーバーヘッドはほぼありません。 また、終了時にはプロセスの後片付けまでしてくれるので、親(この場合はdaemon)が死んだ時に「起動したOpenCodeがポートを握ったままゾンビ化する」みたいなことがなくなります。

PS.

本当に蛇足でしかないんですが、shell版も置いておきます。 ただし、これは環境変数や.envの問題への対応はしていません。

最後の2行は「本来のコマンド」と「shell」を切り替えて試すためです。 何が嬉しいかと言えば、shellを起動するとそこでshellになりますから、色々コマンドを入れて試してみることができるわけです。

#!/usr/bin/env bash
set -euo pipefail

PERSONA="${1:?persona name required}"

BASE="./workers/opencode"

WORKSPACE="${BASE}/workspace"
CONFIG="${BASE}/config"

# mkdir -p "${WORKSPACE}"

exec bwrap \
  --die-with-parent \
  --share-net \
  --unshare-pid \
  --unshare-ipc \
  --ro-bind /usr /usr \
  --ro-bind /bin /bin \
  --ro-bind /lib /lib \
  --dir /etc \
  --ro-bind /etc/resolv.conf /etc/resolv.conf \
  --ro-bind-try /etc/hosts /etc/hosts \
  --ro-bind-try /etc/nsswitch.conf /etc/nsswitch.conf \
  --ro-bind-try /etc/gai.conf /etc/gai.conf \
  --ro-bind-try /etc/services /etc/services \
  --ro-bind /etc/ssl /etc/ssl \
  --ro-bind ./src /src \
  --ro-bind ./node_modules /node_modules \
  --ro-bind ./.env /.env \
  --ro-bind ./config /config \
  --ro-bind ./roles /roles \
  --bind ./workers /workers \
  --bind ./workspace /workspace \
  --bind ./logs /logs \
  --proc /proc \
  --dev /dev \
  --tmpfs /tmp \
  --chdir / \
  /usr/bin/node src/cli/index.js --debug llm --watch --persona ${PERSONA}
    #/bin/bash

PS2.

ChatGPTに書かせたドキュメントを置いておきます。

Legion における隔離実行環境の設計方針

最近のエントリー

Legionのサンドボックス

プロジェクトLegion

ソースコードがまるっと消えた

金曜ごはん#31 「限界突破の1ポンド超えハンバーグナイト」

金曜ごはん#30 「野蒜と焼肉」

金曜ごはん#29「肉の旨味重なるWミートグラタン」

初夏の磯遊び

金曜ごはん#28 「パワーディナー」

おいしいプリン

金曜ごはん#27 「たっぷり唐揚げ」

金曜ごはん#26 「骨付きスペアリブ」

金曜ごはん#25「久しぶりのピザ」

金曜ごはん#24「タンドリーチキンとナン」

金曜ごはん #23 「トースターで焼いた手作りパン」

金曜ごはん #22 「お惣菜と手作りミートソース」

金曜ごはん#21「ケンタッキー風鶏排」

Geminiで音楽が作れるようになったので、いくつかザリガニの曲を作ってみた

Hieronymus MCP計画

金曜ごはん#20 「ケンタッキー風フライドチキン」

AIによるAmazon広告運用システム