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

## daemon / harness / OpenCode を安全に動かすための軽量 sandbox 実装メモ

## 1. 背景

Legion では、LLM や外部 coding harness、たとえば OpenCode のような実行系を daemon / worker として起動し、persona や role に応じて処理を委譲する構造を取る。

このとき問題になるのは、worker が単なる関数ではなく、ファイルシステムを読む・書く・コマンドを実行する・外部 API にアクセスする、といった能力を持つ点である。

特に OpenCode のような coding harness は、便利である一方、権限の広い環境で動かすと次のような問題が起こり得る。

* `.env` などの credential file を読めてしまう
* プロジェクト全体の不要なファイルまで見えてしまう
* workspace 外のファイルを誤って変更できてしまう
* LLM の誘導や tool 実行により、意図しない情報漏洩や破壊的操作が起こり得る
* harness が「自分の実行環境」を過大に仮定してしまい、daemon 全体の構成と混線する

したがって、Legion の worker / harness には、少なくとも「見える範囲」を制限する隔離実行環境が必要である。

ここで重要なのは、現時点で必要なのは Docker や Kubernetes のような大掛かりな実行基盤ではないという点である。まず必要なのは、以下のような実用的な境界である。

```text
- .env を sandbox 内に見せない
- /workspace だけを書き込み可能にする
- /roles や /config は読み取り専用にする
- /logs は書き込み可能にする
- daemon / harness は sandbox 内の / を自分の世界だと思って動く
- 必要な環境変数だけを launcher から渡す
```

この目的には、`bwrap`、すなわち Bubblewrap が適している。

## 2. 隔離環境が必要な理由

### 2.1 LLM worker は通常の子プロセスより危険側に倒して考えるべき

従来の daemon や worker は、開発者が書いたコードである。
もちろんバグはあるが、基本的には意図した処理を実行する。

一方で LLM agent / coding harness は、外部から与えられた prompt、会話履歴、role、tool description、ファイル内容などをもとに、動的に判断する。

そのため、通常のプログラムよりも次の性質が強い。

```text
- 入力により挙動が変わりやすい
- ファイル内容や prompt に誘導される可能性がある
- tool 実行や shell 実行を伴う場合、影響範囲が大きい
- 「悪意はないが余計なことをする」可能性がある
```

したがって、LLM worker には「信頼できる同僚」程度の扱いはできても、「全社機密を自由に読める管理者」として扱うべきではない。

### 2.2 `.env` を見せないだけでも実用上の効果が大きい

最初の防衛線として最も重要なのは、credential file を sandbox 内に置かないことである。

典型的な `.env` には以下が入る。

```text
- LLM API key
- DB 接続情報
- Amazon / Google / OpenAI などの credential
- 内部サービスの token
- 管理用 secret
```

これを sandbox 内に `--ro-bind ./.env /.env` のように見せてしまうと、worker は credentials 一覧表を読める状態になる。

たとえ読み取り専用であっても、LLM harness から見えるファイル空間に credential file が存在すること自体が好ましくない。

したがって、`.env` は sandbox に bind しない。
代わりに launcher が `.env` を読み、必要な環境変数だけを `spawn()` の `env` として worker に渡す。

```text
.env
  ↓ launcher が読む
launcher
  ↓ 必要な env だけ渡す
bwrap
  ↓
sandbox 内 daemon / harness
```

この構造により、`.env` ファイル自体は sandbox 内に存在しないが、必要な API key などは環境変数として渡せる。

### 2.3 worker ごとに「見える世界」を固定できる

sandbox を使うもう一つの利点は、worker に対して固定されたファイルシステム API を提供できることである。

たとえば sandbox 内では次のように見せる。

```text
/src
/node_modules
/config
/roles
/workers
/workspace
/logs
/tmp
```

これにより、daemon / harness はホスト側の実パスを意識しなくてよくなる。

ホスト側では、

```text
./src
./node_modules
./config
./roles
./workers
./workspace
./logs
```

であっても、sandbox 内では常に、

```text
/src
/config
/roles
/workspace
/logs
```

として見える。

これは daemon 側の実装を安定させる。
後でホスト側のディレクトリ構成を変えたり、worker ごとに workspace を分けたりしても、sandbox 内のパス契約を保てば daemon 側の変更は小さく済む。

## 3. 隔離技術の比較

### 3.1 Docker / Podman

Docker や Podman は、コンテナイメージを前提とした実行環境である。

大まかには次のようなモデルになる。

```text
Docker / Podman:
  rootfs をイメージとして用意する
  その上でプロセスを起動する
```

特徴は以下である。

```text
- rootfs をイメージとして持つ
- イメージビルドがある
- registry や tag 管理がある
- overlay filesystem によるレイヤー管理がある
- volume / network / cgroup / logging などの運用機能が厚い
- 配布や再現性に強い
```

Docker / Podman は、アプリケーションを「配布可能な環境」としてまとめたい場合には強力である。

一方で Legion の daemon / harness sandbox に対しては、やや大げさである。

今回必要なのは、次のような軽量な視界制御である。

```text
- この worker には /workspace だけ見せる
- .env は見せない
- /roles は読み取り専用で見せる
- /logs だけ書かせる
- ホストの Node.js と node_modules はそのまま使う
```

この用途では、イメージビルドや registry、コンテナネットワーク設計までは不要である。

### 3.2 chroot

`chroot` は古典的な隔離手段であり、プロセスから見える root directory を変更する。

しかし `chroot` はそれ単体では限定的である。

```text
- mount namespace などの制御は別途必要
- root 権限との関係が面倒
- 現代的な sandbox としては不足が多い
- bind mount や namespace の扱いを自前で組む必要がある
```

軽量ではあるが、Legion のように「必要なディレクトリを組み合わせて専用の rootfs を作る」用途には、`bwrap` の方が扱いやすい。

### 3.3 systemd sandbox

systemd には `ProtectSystem=`, `ReadWritePaths=`, `PrivateTmp=`, `PrivateDevices=`, `RestrictAddressFamilies=` など、service 単位の sandbox 機能がある。

systemd service として daemon を管理するなら非常に有用である。

ただし、Legion 側で worker / persona / harness ごとに動的に sandbox を作りたい場合、systemd unit に寄せると柔軟性が落ちる。

```text
systemd sandbox:
  service 単位の制御には強い
  動的な worker 単位の視界制御にはやや重い
```

Legion の launcher が worker を直接起動する構造では、`bwrap` を `spawn()` する方が自然である。

### 3.4 独自 shell / agent runtime

NVIDIA の OpenShell のように、AI agent 用の安全な実行殻を作る方向もある。

これは意義がある。
特に企業導入や汎用 agent platform を目指す場合、以下のような機能が必要になる。

```text
- sandbox policy DSL
- tool permission
- secret policy
- network policy
- approval flow
- audit log
- skill / tool runtime
- user confirmation UI
```

ただし、この方向に進むと、それ自体が一つの製品・基盤になる。

Legion の現段階で必要なのは、まず OpenCode harness から `.env` を見えなくし、workspace 外のファイルを触りにくくすることである。

したがって、現時点で独自 shell / runtime を作り始めるのは過剰である。

名前を付けると、それ自体を作りたくなる。
今はまだ「bwrap で囲っているだけ」くらいの扱いに留めるのがよい。

### 3.5 bwrap / Bubblewrap

`bwrap` は、Linux namespace と bind mount を使って、プロセスごとの軽量な実行環境を構築する道具である。

Docker / Podman のようにイメージを持つのではなく、ホスト側の既存ディレクトリを組み合わせて、そのプロセス専用の root filesystem を作る。

大まかには次のようなモデルである。

```text
bwrap:
  ホスト上の /usr, /bin, /lib, ./src, ./workspace などを bind mount し、
  そのプロセスからだけ見える / を構成する
```

したがって、Docker / Podman との差は次のように整理できる。

```text
Docker / Podman:
  環境ごと箱に入れて持ち運ぶ

bwrap:
  今いる環境に、プロセスごとの仕切りを作る
```

Legion の用途には後者が合っている。

## 4. bwrap の位置づけ

`bwrap` は「超軽量コンテナ」と言えなくもない。

ただし、Docker / Podman のような全部入りコンテナランタイムではない。

Docker / Podman が持つような機能のうち、`bwrap` が主に担当するのは次である。

```text
- mount namespace
- bind mount
- PID namespace
- IPC namespace
- network namespace の共有または分離
- temporary filesystem
- /proc の構成
- 親プロセス死亡時の終了
```

一方で、次のようなものは `bwrap` 自体の責務ではない。

```text
- イメージ管理
- registry
- overlay layer
- volume 名管理
- container lifecycle manager
- secret manager
- policy DSL
- audit log
- orchestration
```

つまり `bwrap` は、コンテナランタイムというより、

```text
namespace / bind mount 実行器
```

と見るのがよい。

Legion においては次のような役割分担になる。

```text
Legion launcher:
  worker policy を決める
  .env を読む
  必要な env を選別する
  bwrap の引数を構成する
  worker を起動・停止する

bwrap:
  sandbox 内の filesystem view を作る
  namespace を分ける
  プロセスを起動する

OpenCode / daemon:
  sandbox 内の / を自分の世界として動く
```

この設計では、`bwrap` を「基盤の主役」にしない。
あくまで launcher の実装詳細として扱う。

## 5. 現在の sandbox 方針

当初の bash script は、おおむね次のような構成である。

```bash
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}"
```

このうち、基本的な方向性は良い。

特に、

```bash
--chdir /
```

により、daemon は sandbox 内の `/` を基準に動く。

sandbox 内から見ると、次のような構造になる。

```text
/src
/node_modules
/config
/roles
/workers
/workspace
/logs
/tmp
```

daemon はホスト側の実パスを知らず、sandbox 内の固定パスだけを見ればよい。

ただし、次の点は修正すべきである。

```bash
--ro-bind ./.env /.env
```

これは削除する。
`.env` を sandbox に見せない。

代わりに、launcher が `.env` を読み、必要な環境変数だけを worker に渡す。

また、最後の起動パスは相対パスより絶対パスの方が意図が明確である。

```bash
/usr/bin/node /src/cli/index.js
```

## 6. bash launcher と JS launcher の比較

### 6.1 bash で `.env` を読む場合

bash でも次のように `.env` を読むことはできる。

```bash
set -a
source .env
set +a
```

`set -a` により、読み込んだ変数は export され、子プロセスへ渡される。

ただし `source .env` は `.env` を shell script として実行するという意味である。

つまり `.env` に次のようなものが書けてしまう。

```bash
OPENAI_API_KEY=$(some-command)
```

また、`.env` の値を選別して渡す処理を bash で書き始めると、だんだん shell script が設定パーサ兼 policy engine になってしまう。

これはあまり嬉しくない。

### 6.2 JS launcher の方が扱いやすい理由

Legion 自体が JS / Node.js で構成されているなら、launcher も JS で書く方が自然である。

JS launcher にすると、次がやりやすい。

```text
- dotenv parse
- env allowlist / prefix filter
- bwrap 引数の配列化
- persona ごとの policy 分岐
- worker ごとの sandbox 構成
- spawn によるプロセス管理
- exit / signal handling
- 将来的な OpenCode 単体 sandbox 化
```

bash で頑張るより、JS の `spawn()` で `bwrap` を起動する方が、ご機嫌である。

## 7. 実装方針

### 7.1 `.env` は一元管理する

運用上、設定ファイルをいくつもメンテしたくない。
したがって `.env` は単一の設定源として維持する。

ただし、`.env` を sandbox に bind しない。

構造は次のようにする。

```text
.env はプロジェクト直下に置く
launcher だけが .env を読む
launcher が必要な env を選別する
worker には spawn env で渡す
sandbox 内には .env ファイルを存在させない
```

これにより、`.env` を編集すれば全体に反映されるが、worker / harness から `.env` ファイルは見えない。

### 7.2 env は allowlist / prefix で選別する

最初は厳密な secret manager を作る必要はない。

以下のような prefix allowlist で十分である。

```js
const allowedPrefixes = [
  'OPENAI_',
  'GOOGLE_',
  'GEMINI_',
  'ANTHROPIC_',
  'LEGION_',
  'OPENCODE_',
  'NODE_',
];
```

これにより、`.env` にあるすべてを無条件に渡すのではなく、必要そうなものだけを渡す。

ただし、現段階では過度に凝りすぎない。
必要になれば後で worker ごとに policy を細かくできる。

### 7.3 bind mount はまず JS 配列で表現する

`/etc/fstab` のような外部定義ファイルを作りたくなるが、現時点では早い。

外部 DSL 化すると、それ自体が設計対象になる。

まずは JS の配列で十分である。

```js
const binds = [
  ['ro', '/usr', '/usr'],
  ['ro', '/bin', '/bin'],
  ['ro', '/lib', '/lib'],

  ['ro', './src', '/src'],
  ['ro', './node_modules', '/node_modules'],
  ['ro', './config', '/config'],
  ['ro', './roles', '/roles'],

  ['rw', './workers', '/workers'],
  ['rw', './workspace', '/workspace'],
  ['rw', './logs', '/logs'],
];
```

これを `bwrap` の引数に変換する。

```js
const toBindArgs = ([mode, from, to]) => {
  if (mode === 'ro') return ['--ro-bind', from, to];
  if (mode === 'rw') return ['--bind', from, to];
  if (mode === 'ro-try') return ['--ro-bind-try', from, to];

  throw new Error(`unknown bind mode: ${mode}`);
};
```

この程度の構造化は有用である。
しかし、`sandbox-runtime` のような名前を付けてパッケージ化し始めるのはまだ早い。

### 7.4 sandbox 内の標準パスを固定する

worker / daemon から見えるパスは固定する。

推奨する sandbox 内パスは次である。

```text
/src          アプリケーションコード。読み取り専用
/node_modules Node.js dependencies。読み取り専用
/config       設定。原則読み取り専用
/roles        role / skill / prompt。読み取り専用
/workers      worker 関連。現状は書き込み可でもよい
/workspace    実作業領域。書き込み可
/logs         ログ出力。書き込み可
/tmp          一時領域。tmpfs
```

daemon はこの世界だけを前提に書く。

ホスト側の構成が変わっても、launcher が bind mount で吸収する。

## 8. JS launcher 実装例

以下は、`bwrap` を JS から起動する launcher の基本形である。

```js
// src/launcher/run-opencode-daemon.js
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_',
];

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

const daemonEnv = {
  PATH: '/usr/bin:/bin',
  HOME: '/tmp',
  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 bindSpecs = [
  ['ro', '/usr', '/usr'],
  ['ro', '/bin', '/bin'],
  ['ro', '/lib', '/lib'],

  ['ro-try', '/etc/hosts', '/etc/hosts'],
  ['ro-try', '/etc/nsswitch.conf', '/etc/nsswitch.conf'],
  ['ro-try', '/etc/gai.conf', '/etc/gai.conf'],
  ['ro-try', '/etc/services', '/etc/services'],

  ['ro', '/etc/resolv.conf', '/etc/resolv.conf'],
  ['ro', '/etc/ssl', '/etc/ssl'],

  ['ro', './src', '/src'],
  ['ro', './node_modules', '/node_modules'],
  ['ro', './config', '/config'],
  ['ro', './roles', '/roles'],

  ['rw', './workers', '/workers'],
  ['rw', './workspace', '/workspace'],
  ['rw', './logs', '/logs'],
];

const toBindArgs = ([mode, from, to]) => {
  if (mode === 'ro') return ['--ro-bind', from, to];
  if (mode === 'rw') return ['--bind', from, to];
  if (mode === 'ro-try') return ['--ro-bind-try', from, to];

  throw new Error(`unknown bind mode: ${mode}`);
};

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

  '--dir',
  '/etc',

  ...bindSpecs.flatMap(toBindArgs),

  '--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);
});
```

この実装のポイントは次である。

```text
- .env を sandbox に bind していない
- .env は launcher だけが読む
- env は spawn の env で渡す
- secret が bwrap の --setenv 引数に載らない
- bind 定義は JS 配列で管理する
- sandbox 内の / を基準に daemon を動かす
```

## 9. daemon 全体 sandbox と OpenCode 単体 sandbox

現時点では、daemon 全体を sandbox に入れる構成でよい。

```text
Legion daemon 全体
  └─ bwrap sandbox 内で動く
```

この構成は実装が簡単であり、まず「daemon から見える世界」を固定する効果がある。

ただし将来的には、OpenCode のような harness だけを sandbox に入れる方が境界としてはより明確である。

```text
Legion daemon / launcher
  └─ OpenCode worker だけ bwrap sandbox 内で起動
```

この場合、Legion 本体は管理者側に残る。

```text
Legion 本体:
  - .env を読む
  - DB にアクセスする
  - worker を起動・停止する
  - policy を判断する
  - workspace を管理する

OpenCode sandbox:
  - /workspace を見る
  - /roles を読む
  - /logs に書く
  - 必要最小限の env だけ受け取る
  - .env ファイルは見えない
```

これは理想形に近い。

ただし、今すぐやると次の論点が増える。

```text
- daemon 内から bwrap を起動する構成
- worker ごとの mount policy
- persona ごとの writable workspace
- OpenCode の cwd / config / session 保存場所
- ログの逃がし方
- プロセス終了管理
```

したがって、現在は daemon 全体 sandbox でよい。
将来、OpenCode worker 単位 sandbox に移すために、sandbox 内パスだけは今から固定しておく。

## 10. 実装優先順位

現段階でやるべきことは、以下で十分である。

### 10.1 最初にやること

```text
1. .env の bind をやめる
2. JS launcher を作る
3. launcher が .env を読む
4. 必要な env だけ spawn env で渡す
5. bwrap の bind 定義を JS 配列化する
6. daemon は /src /roles /workspace /logs を前提に動かす
```

### 10.2 まだやらないこと

```text
- sandbox policy DSL
- fstab 的な外部 mount 定義
- secret manager
- 独自 shell
- Agent Runtime 製品化
- worker ごとの完全な permission system
- OpenShell 相当の実行基盤
```

これらは将来の拡張としては自然だが、今やると主戦場がずれる。

## 11. 設計上の結論

Legion における現在の sandbox は、Docker / Podman のようなコンテナ基盤ではなく、`bwrap` を使った軽量な視界制御として設計する。

目的は「完璧な隔離」ではなく、まず次を達成することである。

```text
- .env を harness から見えなくする
- worker に不要なファイルを見せない
- workspace / roles / logs の世界を固定する
- daemon / harness のパス前提を安定させる
- 将来的に OpenCode 単体 sandbox へ移行しやすくする
```

この意味で、`bwrap` は Legion の現フェーズにちょうどよい。

Docker / Podman ほど重くなく、chroot より現代的で、独自 shell を作るほど大げさでもない。

現時点では、`bwrap` を JS launcher から `spawn()` し、`.env` を launcher だけが読み、必要な環境変数と mount だけを与える構成が最もコストパフォーマンスが高い。

この実装はまだ「製品化された sandbox runtime」ではない。
むしろ名前を付けずに、単に「bwrap で囲っているだけ」として扱う方がよい。

名前を付けると、それ自体を作りたくなる。

今は、sandbox は主役ではない。
主役は Legion の worker orchestration であり、sandbox はそのための実用的な安全柵である。
