diff --git a/README.md b/README.md index 171a298..760e2bb 100644 --- a/README.md +++ b/README.md @@ -248,22 +248,42 @@ Turn it off with `restore_sessions = false`. ## Sandbox profiles A profile says where a pane runs. `host` is the default and runs directly. -Anything with an `image` runs inside `docker run`. +Anything with an `image` runs inside ` run`. ```toml [profile.sandbox] image = "termit-agent:latest" # an image with your agent installed workdir = "/work" mount = ["{cwd}:/work"] -network = "bridge" env = ["ANTHROPIC_API_KEY"] args = ["--dangerously-skip-permissions"] ``` The isolation comes from the mount scope and the process namespace, not from -the network. The container only sees what you mounted. `network` defaults to -`bridge` because an agent that cannot reach its API is not useful; set -`"none"` for processes that do not need to get out. +the network. The container only sees what you mounted. Leave `network` unset +and the runner's own default applies, which reaches the internet — an agent +that cannot call its API is not useful. Set `network = "none"` under Docker for +a session that does not need to get out. + +`runner` names the command, and defaults to `docker`. Anything that takes the +same arguments works, because termit only assembles the argument list: + +```toml +[profile.vm] +image = "termit-agent:latest" +runner = "container" # Apple's container: one lightweight VM each +mount = ["{cwd}:/work"] +env = ["ANTHROPIC_API_KEY", "TERM"] +runner_args = ["--memory", "2048MB"] # goes in just before the image +``` + +`runner_args` is the escape hatch for flags termit knows nothing about. Two +notes for Apple's `container` specifically, both measured: it gives a container +4 CPUs and 1024 MB by default, which is a lot per session, and for about the +first second the terminal size inside is 0x0 before the real size arrives, so a +full-screen UI that reads its size once at startup can come up at 80 columns. +Starting the agent through `sh -c 'sleep 1.5; exec claude'` is enough to miss +that window. `docs/references/sandbox.md` records the measurements. Profiles are orthogonal to forking: `^]` forks into a profile you pick, so you can move a conversation into a container right before something risky. @@ -334,6 +354,7 @@ The detailed design record is in Japanese. - [`docs/superpowers/specs/2026-09-08-agent-terminal-design.md`](docs/superpowers/specs/2026-09-08-agent-terminal-design.md) — the specification - [`docs/performance.md`](docs/performance.md) — where the time actually goes, measured - [`docs/references/performance-techniques.md`](docs/references/performance-techniques.md) — techniques taken from other terminals, each marked adopted, rejected with the measurement, or still open +- [`docs/references/sandbox.md`](docs/references/sandbox.md) — how agents are sandboxed elsewhere, what Apple's `container` measured at, and what termit deliberately leaves outside - [`docs/references/scrollback.md`](docs/references/scrollback.md) — how five other implementations handle scrollback, and which parts were copied - [`docs/warp-metrics.md`](docs/warp-metrics.md) — the sizes and paddings the left pane is based on diff --git a/docs/references/sandbox.md b/docs/references/sandbox.md new file mode 100644 index 0000000..e56634a --- /dev/null +++ b/docs/references/sandbox.md @@ -0,0 +1,134 @@ +# サンドボックスとエージェント + +作成日:2026-09-10 + +エージェントを隔離して走らせる方法を調べ、Apple の `container` を実際に試した記録。 +termit が何を担い、何を担わないかの線引きもここに置く。 + +## 1. 隔離の段階 + +Anthropic 自身が 6 段階で整理している([Choose a sandbox environment](https://code.claude.com/docs/en/sandbox-environments))。 + +| 手法 | 何が隔離されるか | Docker | 手間 | +|---|---|---|---| +| Bash サンドボックス(`/sandbox`) | Bash とその子だけ | 不要 | macOS はほぼゼロ | +| sandbox runtime | Claude Code のプロセス全体(ファイル操作・MCP・hook も) | 不要 | 小 | +| dev container | 開発環境まるごと | 必要 | 中 | +| 任意のコンテナ | 同上(自前のポリシー) | 必要 | 中〜大 | +| 仮想機械 / microVM | OS まるごと(自前カーネル) | 不要 | 大 | +| Claude Code on the web | Anthropic 管理の VM | 不要 | なし | + +実装は OS の機能そのもので、**macOS は Seatbelt(`sandbox-exec`)、Linux は bubblewrap + seccomp**。 +Codex CLI も同じ(Seatbelt / Landlock + seccomp)。 +ネットワークはいずれも**ホスト側プロキシの許可リスト**で止める。 + +重要な区別が 2 つある。 + +- **権限モード**(いつ聞くか)と**隔離境界**(できたとして何に届くか)は別物である。 + Anthropic は「`--dangerously-skip-permissions` を使うならコンテナか VM か + sandbox runtime の中で」と明記している。 +- **Bash サンドボックス単体では無人実行に足りない。** + MCP サーバと hook はホストで素のまま動く。 + +ファイル側の隔離は **worktree per task**(タスクごとに git worktree)が定番になっている。 +`sbx` はこれを内蔵していて、`--branch auto` で `.sbx/` の下に worktree を作る。 + +## 2. `sbx`(Docker Sandboxes) + +コンテナではなく **microVM 1 個 / セッション 1 個**。 + +- 独立カーネル、独立した docker daemon、workspace だけ passthrough マウント、 + 外向き通信はすべてホスト側プロキシ経由。 +- **認証情報はホストに残す。** `sbx secret set` で OS キーチェーンに入れ、 + プロキシが送信時に注入する。環境変数で中に渡さない。 +- ネットワークは Open / Balanced / Locked Down の 3 段階。 +- `brew install docker/tap/sbx`(macOS 14 以降 / Apple silicon / Docker Desktop 不要)。 +- 利用報告では**性能が最大の難点**、コミット署名(ssh-agent)を中に渡せない、 + メモリ既定はホストの 50%。 + +引数体系が `sbx run ` で docker と違うため、termit の `runner` では扱えない +(4 節)。 + +## 3. Apple の `container` を試した(2026-09-10、1.4.1) + +`brew install container` → `container system start` → +`container system kernel set --recommended`(kata 3.32.0)。 + +| 試したこと | 結果 | +|---|---| +| 初回実行(イメージ取得込み) | 15.1s | +| 2 回目以降の起動 | **0.74s**(VM 1 個ぶん込み) | +| 対話シェルの往復(pty 経由、プロンプトまで) | **1.02s**。打鍵も出力も正常 | +| `-v {cwd}:/work -w /work` | 読み書きとも可 | +| `-e TERM` | ホストの値が入る(既定は `xterm`) | +| `--network` | ネットワーク**名**を取る。既定 `default` は 192.168.64.0/24、コンテナごとに IP が 1 個 | +| 常駐(コンテナ 0 個) | apiserver 24MB + core-images 22MB ≒ 46MB | +| コンテナ 1 個あたり | ホスト側 `container-runtime-linux` が約 21MB、ゲストは既定 **4 CPU / 1024MB** | + +`-v` `-w` `-e` `--network` `-it` `--rm` の綴りが docker と同じなので、 +termit が組み立てる引数列は先頭を替えるだけで通る。 + +### 起動直後の 1 秒、端末の大きさが 0x0 になる + +外側の pty には exec の前に 40x120 を入れてある(親が後から入れる競走は避けた)。 +それでもゲスト側は: + +``` +t1: 0 0 ← コンテナ起動直後 +t2: 40 120 ← 1 秒後に正しい値が届く +… +(ホスト側で 50x200 に変えて SIGWINCH) +t6: 50 200 ← リサイズは正しく伝わる +``` + +**欠けているのは起動時の初期値だけ**で、リサイズの経路は動いている。 +`tput cols` はこのあいだ terminfo の 80 に落ちる。 +起動時に一度だけ大きさを読む全画面 UI は、80 桁で描き始めることになる。 + +termit 側で塞ごうとすると厄介である。同じ大きさで `TIOCSWINSZ` を呼んでも +SIGWINCH は出ない(大きさが変わったときだけ送られる)ので、 +一度違う値にしてから戻す必要があり、画面がちらつく。 +**設定で避けられる**ので、そちらを案内する。 + +```toml +# エージェントの起動を少し遅らせる。1 秒の窓を外すだけでよい。 +[agent] +new = "sh -c 'sleep 1.5; exec claude --session-id {new_id}'" +``` + +上流には初期値の件の報告は無い([#1747](https://github.com/apple/container/issues/1747) は +SIGWINCH 転送のエラー表示の話)。 + +## 4. termit が担うもの、担わないもの + +termit がやるのは**引数列を組み立てて起動するところまで**である。 + +**入れた(`runner` / `runner_args`)。** +`runner` は既定 `docker`、`runner_args` はイメージ名の直前に入る逃げ道。 +Apple の `container` はこれで通り、`--memory` のような道具固有の指定も書ける。 +`network` は書かなければ渡さない(名前が道具ごとに違うため)。 +termit のコードに道具の名前は一つも増えていない。 + +**入れない:`sbx` 用の分岐。** +`sbx run ` は引数の並びが違うので、いまの形では扱えない。 +対応するなら「起動コマンドの雛形」への一般化が要る。 +それは `image`/`mount`/`network` という語彙を捨てることでもあるので、 +必要になってから決める。 + +**入れない:fork のときに git worktree を作る。** +並列エージェントの定番だが、端末が git を理解し始める。 +`[agent] fork` の雛形に `git worktree add` を書けば外側で足りる。 + +**入れない:認証情報の注入。** +`env` で渡すのはホストと同じ強度しかない。 +`sbx` のようなプロキシ注入は端末の仕事ではない。README にその旨を書く。 + +## 参考 + +- [Choose a sandbox environment](https://code.claude.com/docs/en/sandbox-environments) +- [Configure the sandboxed Bash tool](https://code.claude.com/docs/en/sandboxing) +- [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) / [sbx のインストール](https://docs.docker.com/ai/sandboxes/install/) +- [Running AI agents safely in a microVM using docker sandbox](https://andrewlock.net/running-ai-agents-safely-in-a-microvm-using-docker-sandbox/) +- [apple/container](https://github.com/apple/container) — [ドキュメント](https://apple.github.io/container/documentation/) +- [Codex CLI サンドボックスの調査](https://agent-safehouse.dev/docs/agent-investigations/codex) +- [AI エージェント用サンドボックスの比較(E2B / Modal / Daytona ほか)](https://blog.logrocket.com/comparing-ai-agent-sandbox-platforms-e2b-modal-daytona-and-more/) diff --git a/docs/superpowers/specs/2026-09-08-agent-terminal-design.md b/docs/superpowers/specs/2026-09-08-agent-terminal-design.md index 2a52bf8..e4e119d 100644 --- a/docs/superpowers/specs/2026-09-08-agent-terminal-design.md +++ b/docs/superpowers/specs/2026-09-08-agent-terminal-design.md @@ -194,7 +194,13 @@ args = ["--dangerously-skip-permissions"] **mount**:`docker run -v` に渡すマウント指定。`{cwd}` を展開する。 -**network**:`docker run --network` に渡す値。既定は `bridge`。 +**runner**:イメージを起動する道具。既定は `docker`。 +docker と同じ並びの引数を取るものなら何でもよい(Apple の `container` など)。 + +**runner_args**:イメージ名の直前に差し込む引数。道具ごとの指定に使う。 + +**network**:`--network` に渡す値。書かなければ渡さない(道具の既定に従う)。 +名前は道具ごとに違う(docker は `bridge`、`container` は `default`)。 **env**:ホストから引き継ぐ環境変数名。値ではなく名前だけを書く。 @@ -207,7 +213,6 @@ args = ["--dangerously-skip-permissions"] ``` docker run --rm -it \ -v /Users/tkc/repo:/work -w /work \ - --network bridge \ -e ANTHROPIC_API_KEY \ tex-agent:latest \ claude --session-id --dangerously-skip-permissions @@ -217,8 +222,10 @@ docker run --rm -it \ コンテナから見えるファイルは `mount` に書いた範囲だけであり、ホストの他のディレクトリ、ホストのプロセス、ホストにインストールされたコマンドには届かない。 権限確認を省く `--dangerously-skip-permissions` を使えるのは、この範囲の限定が効いているときに限る。 -`network` の既定を `bridge` とするのは、モデル API への接続が切れるとエージェントが動かないためである。 -外部へ出る必要のないプロセスには `network = "none"` を明示的に指定する。 +`network` を書かなければ何も渡さないのは、道具ごとに名前が違うためである +(docker は `bridge`、Apple の `container` は `default`)。 +どちらも既定で外へ出られる。モデル API への接続が切れるとエージェントが動かないので、これでよい。 +外部へ出る必要のないプロセスには docker なら `network = "none"` を明示する。 termit は `network` と `args` の組み合わせを検査しない。 どの隔離が必要かはエージェントと作業の性質で決まり、端末が判定できる事柄ではない。 diff --git a/src/config.rs b/src/config.rs index fc659b6..649cca3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -108,12 +108,26 @@ pub struct AgentConfig { pub struct Profile { /// 使用するイメージ。省略するとホスト上で直接起動する。 pub image: Option, + /// イメージを起動する道具。docker と同じ並びの引数を取るものなら何でもよい。 + /// + /// Apple の `container` は同じ綴りの `-v` `-w` `-e` `--network` を持つ。 + /// termit は引数を組み立てて起動するだけで、中身のことは知らない。 + #[serde(default = "default_runner")] + pub runner: String, + /// イメージ名の直前に差し込む引数。道具ごとの細かい指定に使う。 + /// + /// 例:`["--memory", "2048MB"]`。termit は中身を見ない。 + #[serde(default)] + pub runner_args: Vec, #[serde(default = "default_workdir")] pub workdir: String, #[serde(default)] pub mount: Vec, - #[serde(default = "default_network")] - pub network: String, + /// 繋ぐネットワーク。省略すると道具の既定に任せる。 + /// + /// 道具ごとに名前が違う(docker は `bridge`、`container` は `default`)。 + /// どちらも既定で外へ出られるので、閉じたいときだけ書く。 + pub network: Option, #[serde(default)] pub env: Vec, #[serde(default)] @@ -123,18 +137,19 @@ pub struct Profile { fn default_workdir() -> String { "/work".to_string() } -fn default_network() -> String { - // モデル API への接続が切れるとエージェントが動かないため bridge を既定とする。 - "bridge".to_string() +fn default_runner() -> String { + "docker".to_string() } impl Default for Profile { fn default() -> Self { Self { image: None, + runner: default_runner(), + runner_args: Vec::new(), workdir: default_workdir(), mount: Vec::new(), - network: default_network(), + network: None, env: Vec::new(), args: Vec::new(), } @@ -244,6 +259,12 @@ impl Config { ))); } } + if p.runner.trim().is_empty() || p.runner.contains(char::is_whitespace) { + return Err(ConfigError::Invalid(format!( + "profile.{name}.runner \"{}\" must be a single command name", + p.runner + ))); + } for e in &p.env { if e.contains('=') { return Err(ConfigError::Invalid(format!( @@ -459,7 +480,7 @@ pub fn build_argv(profile: &Profile, cwd: &Path, command: &[String]) -> Vec Vec write!(f, "cannot build command: {e}"), SessionError::Spawn(e) => write!(f, "{e}"), - SessionError::NoDocker(p) => { - write!(f, "profile.{p} needs docker, but docker was not found") + SessionError::NoRunner { profile, runner } => { + write!( + f, + "profile.{profile} needs {runner}, but {runner} was not found" + ) } } } @@ -564,8 +567,11 @@ impl Manager { key: Option, ) -> Result { let profile = config.profile(profile_name); - if !profile.is_host() && !docker_available() { - return Err(SessionError::NoDocker(profile_name.to_string())); + if !profile.is_host() && !runner_available(&profile.runner) { + return Err(SessionError::NoRunner { + profile: profile_name.to_string(), + runner: profile.runner.clone(), + }); } let argv = config::build_argv(&profile, cwd, &base); let id = self.next_id; @@ -771,8 +777,9 @@ fn shell_argv(config: &Config) -> Vec { argv } -fn docker_available() -> bool { - std::process::Command::new("docker") +/// その道具が入っているか。起動して失敗するより先に知らせる。 +fn runner_available(runner: &str) -> bool { + std::process::Command::new(runner) .arg("--version") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null())