DeepSeek Harness のツール実行パイプラインには、上級開発者を魅了しつつも恐れさせる 2 つの段階があります。1 つはコマンドがどの境界で実行されるかを決める ctx.sandbox、もう 1 つは今回の具体的な操作を許可するかどうかを決める ctx.approval です。前者は制約を、後者は認可を司り、一見それぞれの役割を果たしているように見えますが、同じ一線を共有しています——デフォルトでフェイルクローズです。言い換えれば、システムがある事柄が安全かどうかを確認できないとき、答えは通すことではなく、遮ることです。本記事は、すでにツールプラグインを書き、プラグインのライフサイクルとツール登録の仕組みを理解している上級読者を対象に、「危険な操作がどの 2 つのゲートを通らなければならないか」という主線に沿って、サンドボックス決定、承認フロー、SandboxMode の 3 段階権限、enforcement 完全性レポート、ポリシーの 3 層フォールバック順序、そして workspaceRoot の正規化セマンティクスを 1 つずつ分解していきます。前半を読み終えれば、あなたは 1 つの問いに正確に答えられるはずです。Agent が危険なコマンドを実行しようとしたとき、Harness は一体どの点でそれを檻に閉じ込めるのか、そしてそれぞれの檻の錠は具体的に何を施錠しているのか。
サンドボックス決定 + 承認フロー全景:危険な操作が通らなければならない 2 つのゲート
まず 2 つのサービスを同じ図に置いて見てみましょう。それらが同じ問いの 2 つの側面に答えていることを理解するためです:Agent がリスクのあることをしようとしているとき、どうやってそれを制約するか。左側はサンドボックスが argv をどのようにラップするか、右側は承認がどのように一度限りの決定を下すかです。サンドボックスは「プロセスがどのファイルに触れられるか」を囲い込み、承認は「今回の操作を通すかどうか」を応答者に委ねます。どちらも事後的な補修ではなく、アクションが実際にオペレーティングシステムに到達する前に裁定を完了します。
ここで最初に確立すべき直感は責務の分離です。サンドボックスが関心を持つのは「この操作をすべきかどうか」ではなく、「もし行うなら、プロセスが到達できるファイルシステムの範囲はどれくらいか」です。これは境界の問題です。承認が関心を持つのは「プロセスがどこまで走れるか」ではなく、「今回、この具体的な操作が現在許可されているか」です。これは認可の問題です。両者を混同することは、多くのプラグイン作者が最初に踏む落とし穴です。サンドボックスに入れば万事安心だと思ったり、承認が通ればサンドボックスを無視できると思ったりすることです。実際には、ある操作はサンドボックスに包まれつつ承認で通されることもあれば、サンドボックス内で拒否されたり、承認で却下されたりすることもあります。2 つの経路はそれぞれ独立して効きます。
サンドボックス決定の入口は ctx.sandbox.confine(argv, policy) で、正確な argv を消費し、ラップ結果を返します。承認フローはサンドボックスの前または外で、「実行するかどうか」について一度限りの判断を下します。「一度限り」という言葉が重要であることに注意してください。承認はあるツールに永久の青信号を与えるのではなく、ある 1 回の呼び出しに対して下される決定です。次に同じ操作が来ても、再びプロセスを通さなければなりません。この設計は認可の粒度を単一呼び出しレベルまで押し下げ、広範な「許可」が後続の呼び出しにひっそりと継承されるのを防ぎます。
また、全編を通じて先に明確にしておかなければならない結論があります:サンドボックスと承認はどちらもデフォルトでフェイルクローズします。フェイルクローズとは、依存するコンポーネントが利用できない、情報が不完全、または実行の完全性が約束に達しないとき、システムが通すのではなく実行を拒否する傾向があることを指します。保守的に聞こえますが、ファイルを読み書きし、プロセスを起動できる Agent フレームワークにとって、いかなる「サイレントな通過」も潜在的な災難の入口です。フェイルクローズはスローガンではなく、コードパスに具体的な現れがあります:制限付きポリシー下では、利用可能なバックエンドがないとき ctx.sandbox.confine が SandboxUnavailableError をスローし、エラーコードは SANDBOX_UNAVAILABLE です。消費側が partial の強制実行完全性を受け取ったとき、絶対的な境界を要求するなら、partial を「不十分」として扱わなければなりません。これらの詳細は後で 1 つずつ展開します。
2 つのゲートの入出力を揃えておくと、エンジニアリング上の正確さを確立するのに役立つ。Sandbox 側の入力は正確な argv と 1 つのポリシーオブジェクトであり、出力は置き換え後の argv とバックエンドが実際に達成した強制実行の事実である。Approval 側の入力は 1 回の具体的な操作リクエストであり、出力は許可または拒否の一度きりの判定である。両方のパスに共通するのは、「デフォルトで実行する」を信用せず、どちらも明示的に何らかの制御されたステップを経由することを要求する点である。これを理解したうえで具体的なモード段階やフォールバック順序を見れば、それらは散発的なルールではなく、同じセキュリティ哲学が異なる層に投影されたものだと感じられるはずだ。
ctx.sandbox.confine(argv, policy):なぜ shell 文字列ではなく正確な argv を渡さなければならないのか
プロセス Sandbox のモデルは一文に要約できる:消費側は正確な argv を渡し、バックエンドがファイル効果ポリシーに従ってそれをラップする。ここでの一つ一つの言葉に重みがある。「消費側」とは Sandbox を呼び出すツールや Plugin を指し、「バックエンド」とは隔離の実施を実際に担う下層実装を指し、「ファイル効果ポリシー」とは今回のラップで達成すべきファイルシステム制約の目標を指す。
なぜ shell 文字列ではなく「正確な argv」を強調するのか。Sandbox が判断し制約すべきは、プロセスが実際に何を実行するかだからである。shell 文字列は解釈される前の段階では、どのパスに触れるのか、変数展開によって想定境界から逃げ出さないか、はっきり言うのが難しい。それに対して argv 配列は、すでに分割済みのプログラム名と引数リストであり、意味が確定していて二次解析がない。これはまさにセキュリティ境界が最も必要とする性質である:曖昧さがない。したがって、消費側が本質的に shell 形態であるなら、コマンドを ['bash', '-c', command] のような argv 形式に包んで Sandbox に渡さなければならず、裸の文字列をそのまま押し込んではならない。これはスタイルの好みではなくモデルの要求である。Sandbox が消費するインターフェースシグネチャは argv なのだ。
戻り値の構造もまた分解する価値がある。ctx.sandbox.confine が返すのは ConfinedArgv であり、これには 2 つの情報が含まれる:置き換え後の argv と、バックエンドの強制実行の事実である。第一部分は理解しやすい——バックエンドは元の argv の外側にラッパーを被せたり、実行ファイルのパスを書き換えたりして、実際のプロセスを制御された環境で起動させる必要があるかもしれない。第二部分こそ上級の読者が重点的に注目すべきもので、「バックエンドがどこまでやれたと主張しているか」に答える。この 2 つの情報はどちらも欠かせない。置き換え後の argv だけを見て直接 spawn すると、バックエンドが実は部分的な隔離しか実装していないことを見落とすかもしれない。enforcement だけを見て置き換え後の argv を使わなければ、spawn はそもそも Sandbox に入らない。正しい姿勢は両方を消費することである。
以下の TypeScript の例は、「ポリシーを解決 → argv を渡す → danger-full-access と制限モードを区別する → ConfinedArgv を消費する」というパスを完全につないでおり、Plugin の骨組みとしてそのまま貼り付けて使える。
// ファイルパス:my-plugins/sandbox-demo/src/index.ts
// Sandbox 化された消費側のデモ:まずポリシーを解決し、次に ctx.sandbox に argv をラップさせる。
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'sandbox-demo'
export const inject = ['tools', 'sandbox', 'sandboxPolicy']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'sandboxed_echo',
description: 'Run echo inside the sandbox.',
parameters: {
text: { type: 'string', required: true, description: 'Text to echo' },
},
output: { schema: { type: 'string' } },
async execute(args, exec) {
// 1. 今回の呼び出しの完全なポリシーを解決する:セッション cwd がワークスペース境界である。
const policy = ctx.sandboxPolicy.resolve({ session: exec.agent?.session })
// 2. 消費側は正確な argv(プログラムと引数)を渡す。shell 文字列ではない。
const argv = ['bash', '-c', `echo ${JSON.stringify(args.text)}`]
// 3. danger-full-access は直接 spawn する。それ以外は Sandbox にラップを任せる。
if (policy.mode === 'danger-full-access') {
const { spawn } = await import('node:child_process')
// ... spawn(argv) して出力を収集する
return `echo ${args.text}`
}
// 4. 制限モード:confine は置き換え後の argv を返し、バックエンドがなければ SANDBOX_UNAVAILABLE を投げる。
const confined = ctx.sandbox.confine(argv, {
mode: policy.mode,
workspaceRoot: policy.workspaceRoot,
})
// 5. 消費側はさらに confined.argv を spawn し、confined.enforcement に応じて full を要求するか決める。
if (confined.enforcement === 'partial' && policy.mode !== 'read-only') {
return {
isError: true,
error: { message: 'partial enforcement is not acceptable' },
}
}
return `confined echo ${args.text} (enforcement: ${confined.enforcement})`
},
}))
}
このコードには、個別に取り上げる価値のあるエンジニアリング上の要点がいくつかある:
- inject による依存関係の宣言:プラグインは tools、sandbox、sandboxPolicy の 3 つのサービスを明示的に注入し、apply 段階でそれらがすでに利用可能であることを保証する。これにより、実行時になって初めてサンドボックスサービスが欠けていることに気づく事態を避けられる。
- argv は自己整合的でなければならない:ユーザー入力を JSON.stringify で包んでからコマンドに連結するのは、echo の引数をシェル内で 1 つのまとまりとして保つためである。同時に、真のセキュリティ境界は文字列連結ではなくサンドボックスに依存するということを読者に思い起こさせる。
- danger-full-access 分岐は自ら spawn する:この段階では ctx.sandbox をまったく呼び出さず、消費側が自分の行為に対して全責任を負う。
- partial の明示的な処理:非 read-only モードで partial を取得した場合、サンプルは直接エラーを返す。これは「足りなければ足りない」をコードに落とし込む具体的なやり方である。
もう 1 つ見落としやすい細部がある:confine は例外を投げる可能性がある。利用可能なバックエンドがない場合、それは降格された ConfinedArgv を返すのではなく、エラーコード SANDBOX_UNAVAILABLE を伴う SandboxUnavailableError を直接投げる。これは、消費側が try/catch を使うか、エラーを上位へバブルさせる必要があることを意味し、ラップされた結果が得られないからといって自分で生の spawn をでっち上げて代用してはならない——それはまさに「静かな非隔離パススルー」であり、明確に禁止されている行為である。ここでの fail-closed は次のように表れる:呼び出しを失敗させる方が、隔離なしでこっそり動かすよりもよい。
SandboxMode の 3 段階権限:read-only、workspace-write、danger-full-access の境界
SandboxMode はサンドボックスポリシー全体の中で最も直感的な部分だが、必ず覚えておくべき限定がある:それはファイルシステム効果のみを管理し、ネットワークやプロセスの可視性は含まない。つまり、これらの段階が解決するのは「プロセスがどのファイルを読み書きできるか」であり、「プロセスがネットワークに接続できるか」「プロセスが他のプロセスを見られるか」ではない。この境界をしっかり頭に入れておけば、後で enforcement を議論するときに混同しない。
3 つの権限段階は開放度の順に進み、各段階を次のように説明する:
- read-only:必要なデータ受け口のみを許可する。たとえば /dev/null のような書き込みを破棄する対象であり、それ以外の書き込みは一律拒否される。データを読むだけ、計算を行う、外側の消費側に結果を出す、といったツールに適している。ここでのキーワードは「必要なデータ受け口」である——すべての書き込みが一律に拒否されるのではなく、ホワイトリスト的な受け口の集合が保持される。
- workspace-write:read-only に加えて、ワークスペースのルートディレクトリおよびバックエンドが約束する一時領域の下への書き込みを追加で許可する。この段階はほとんどのコード系ツールのデフォルトの作業範囲である:プロジェクトディレクトリ内にファイルを生成でき、かつシステムパスに越境して触れることはない。ここで「バックエンドが約束する一時領域」というのは正直な言い回しである——一時領域が具体的にどこにあり、どれくらいの大きさかは、モード定義そのものではなくバックエンド実装に依存する。
- danger-full-access:隔離を直接バイパスし、消費側が自分で生の argv を spawn し、ctx.sandbox をまったく呼び出さない。サンドボックスフローに入らないため、「バックエンドがどれだけ強制を達成したか」という話もない。この段階は、リスクを明確に理解しており、隔離のしようがないシナリオでのみ使用すべきである。
三つの権限ティアのファイルシステムへの影響を一枚の表で比較することが、「なぜ自分の書き込みが拒否されたのか」といった問題を切り分ける最速の方法です:
| SandboxMode | ファイルシステム読み取り | ファイルシステム書き込み | サンドボックスフローに入るか | 典型的な用途 |
|---|---|---|---|---|
| read-only | 許可 | /dev/null など必須のデータ受け口のみ許可、それ以外は拒否 | はい、提供側に委ねる | 読み取り専用分析、純粋な計算、整形出力 |
| workspace-write | 許可 | ワークスペースルートおよびバックエンドが約束する一時領域を許可 | はい、提供側に委ねる | コード生成、ログ出力、ビルド成果物 |
| danger-full-access | 隔離制約なし | 隔離制約なし | いいえ、消費側が自ら spawn する | グローバルアクセスが確実に必要で隔離しようがない特殊な操作 |
エンジニアリング上よくある誤解があります。SandboxMode を「ネットワークスイッチ」とみなすことです。そうではありません。read-only はプロセスがネットワークに接続できないことを意味しませんし、workspace-write もネットワークが制限されることを意味しません。読み取り専用かつオフラインの環境が必要なら、サンドボックスの外で別途ネットワークポリシーを扱う必要があります。もう一つの誤解は、workspace-write の書き込み範囲を「カレントディレクトリとそのサブディレクトリ」という素朴な理解で捉えることです。実際のセマンティクスはワークスペースルート配下への書き込みに、バックエンドが約束する一時領域を加えたものであり、ルートをどう決めるかは後述する workspaceRoot の導出ルールに依存します。cwd に symlink や .. が含まれる場合、あなたの直感はプロセスが実際に動作するディレクトリと一致しない可能性が高いです。この点は workspaceRoot の節で展開します。
最後に、三つの権限ティアには覚えておくべき非対称性がもう一つあります。提供側に送られるのは read-only と workspace-write だけであり、danger-full-access はそもそもサンドボックスに入りません。この非対称性は省略ではなく、セキュリティ性質の一部であり、次の節で詳しく扱います。
なぜ danger-full-access はサンドボックスに入らないのか:制限付き実行は必ず ctx.sandbox に到達するというセキュリティ性質
多くの人は初めて danger-full-access がサンドボックスに入らないのを見たとき、これは実装上の手抜きにすぎないと感じます。どうせ全部開いているのだから、わざわざサンドボックスフローをもう一度通す必要はないだろうと。しかしこれをセキュリティ性質の枠組みで見ると、この設計は意図的であり、むしろ必須であることが分かります。
まず事実を明確にします。提供側に送られるのは read-only と workspace-write の二つのモードだけで、danger-full-access はそもそもサンドボックスに入りません。これはサンドボックスの confine 呼び出しが、本当に制限された場面でのみ発生することを意味します。ここから重要なセキュリティ性質が導かれます。制限付き実行は必ず ctx.sandbox に到達し、静かな無隔離パススルーは決して正当ではない。
この性質を分解して理解しましょう。「制限付き実行」とは、ポリシーが read-only または workspace-write の状態にあることを指します。この状態で、消費側がプロセスを動かしたいなら、唯一の正規の経路は ctx.sandbox.confine を呼び出すことです。もしサンドボックスを迂回して直接 spawn するなら、それは「無隔離パススルー」です。名目上は制限されているのに、実際には丸裸で走っている。この性質が保証するのは、そのようなパススルーがシステムによって決して黙認されないことです。それは「どうせバックエンドがないのだから、全部開いているとみなしてしまおう」という劣化経路を与えません。逆に、利用可能なバックエンドがないとき confine は SANDBOX_UNAVAILABLE を投げ、制限付き実行が無隔離のまま完了することを不可能にします。
逆に見れば、danger-full-access がなぜサンドボックスに入らないのかは明確になる。それはその意味論がそもそも「隔離なし」だからだ。それでも confine を呼ばせるとしたら、サンドボックスは何も包んでいない argv を返すか(それは自己欺瞞だ)、あるいは「隔離なし」というモードのためにも一連のバックエンド挙動を定義しなければならなくなる(それは危険を常態化することだ)。そうするよりは、明示的にサンドボックスに入らないようにして、「全開」であることをコード経路上で可視化し、監査可能にしたほうがよい。消費側は danger-full-access 分岐の中で自ら child_process を import して自ら spawn するので、コードを読む人はここに隔離がないことを一目で見て取れる。
この性質がプラグイン作者の実践に求める要件は次のとおりだ:
- 制限モードでは、try/catch の後に元の spawn へフォールバックするようなロジックを書いてはならない。SANDBOX_UNAVAILABLE を catch したら、自力で降格するのではなく上へ露出させるべきだ。
- 「どうせ読み取りは止められない」と判断して、制限付き実行に裏口を開けようとしてはいけない。モジュールの境界はファイルシステム効果であり、書き込みが拒否されたならそれは拒否なのだ。
- 本当にグローバルアクセスが必要なら、モードを明示的に danger-full-access に切り替え、その決定をポリシー層に露出させるべきであり、どこかの if の中に隠してはならない。
言い換えれば、サンドボックス設計は「暗黙の全開」を拒否する。全開は許されるが、明言されなければならない。これが、この性質を公理として覚えておく価値がある理由だ。それは「隔離が行われるかどうか」を、実行時の偶発的な事実から、ポリシー層で審査できる確定した事実へと変える。
enforcement: 'full' | 'partial':バックエンドが実際に達成した強制執行の完全度をどう報告するか
ConfinedArgv の二つ目の情報——強制執行の事実——は enforcement フィールドで表現され、値は二つだけだ:'full' または 'partial'。これは自己申告の完全度指標であり、隔離が起きたかどうかのブール値ではない。
意味論的には、full はバックエンドがそのモードが約束するすべてのファイル効果を管理していることを意味する。「そのモードが約束する」という限定に注意せよ。モードごとに約束する範囲は異なり、read-only は必要なデータ受け渡し先のみを許可しそれ以外の書き込みを拒否することを約束し、workspace-write はワークスペースルートと一時領域が書き込み可能であることを約束する。full はこれらの約束がすべて果たされていることを意味する。partial は、バックエンドがそのうちの一部のサブセットしか管理していないこと——何かはやったが、全部はやっていないこと——を意味する。
公式ドキュメントが現在挙げている部分的な強制執行の状況は二つある:古い Landlock ABI と、Windows ACL runner の Everyone およびハードリンク境界だ。前者は Linux 側のカーネルインターフェースのバージョン差であり、古い ABI が表現できる制約は限られ、バックエンドはそのモードが約束するすべての効果を覆えない。後者は Windows 側で ACL runner を通じて隔離を実施する際に、Everyone プリンシパルとハードリンク境界で遭遇する限界だ。これらの状況はいずれも「バックエンドが壊れている」のではなく、「バックエンドは特定のプラットフォームではこれしかできない」ということだ。それらを正直に partial として報告することは、黙って full のふりをするよりもはるかに安全だ。
消費側が partial に対して正しく対処する方法は、その境界に対する要求がどれほど厳格かによって決まる。ドキュメントが示す原則はこうだ:絶対的な境界を要求する消費側は、partial を「不十分」として扱い、拒否するか、その区別を上へ露出させなければならない。この文には二つの行動選択肢がある——拒否するか、区別を上位層に露出させるかだ。どちらを選ぶかはツールの性質による。その操作の結果が不可逆で影響範囲が大きいなら、直接拒否するほうが安全だ。上位層が partial に基づいてより細かい判断を下せるなら、enforcement 値をそのまま上へ渡し、意思決定者に知らせるべきだ。
1 つの表で full と partial の処理方針を明確に対比します:
| enforcement の値 | 意味 | 現在知られている発動状況 | 境界要件が厳しい消費側はどう扱うべきか |
|---|---|---|---|
| full | バックエンドがそのモードで約束したすべてのファイル効果を制御している | なし(正常に達成) | そのまま実行を続けてよい |
| partial | バックエンドが約束した効果の一部のサブセットしか制御していない | やや古い Landlock ABI;Windows ACL runner の Everyone とハードリンクの境界 | 「不十分」とみなし、拒否するか、この違いを上位に露出させる |
エンジニアリング上、見落とされやすい細かい点がいくつかあります。第一に、partial はエラーコードではありません。confine は partial を理由に例外を投げず、通常どおり ConfinedArgv を返し、判断を消費側に委ねます。これは SANDBOX_UNAVAILABLE の性質とは異なります。後者は「利用可能なバックエンドがそもそも存在しない」ことであり、前者は「バックエンドはあるが能力が不完全」ということです。第二に、サンプルコードでは非 read-only モードの場合にのみ partial を不十分として扱っており、これはあるニュアンスを示唆しています。read-only モードでは enforcement が partial であっても、約束する範囲はもともと非常に小さく、リスクは比較的制御可能です。一方、workspace-write は書き込み可能を約束しており、partial は書き込み境界のある次元が完全には制御されていないことを意味するため、この場合は拒否するほうが合理的です。第三に、消費側は enforcement を任意の装飾的な情報として扱うべきではありません。それはセキュリティ判断の入力の 1 つであり、明示的に消費しなければなりません。
もう 1 つ実践的な提案があります。デバッグやログに enforcement の実際の値を記録すること、特にクロスプラットフォーム展開時には重要です。同じツールでも Linux と Windows では異なる enforcement を得る可能性があります。それを記録しておけば、問題発生時にポリシーの問題なのかプラットフォーム能力の問題なのかを素早く判断できます。これも「違いを上位に露出させる」低コストな実装方法の 1 つです。
ポリシー解決の 3 層フォールバック順序:承認済みの明示モード > セッションの sandbox/mode イベント > デプロイ既定モード
1 回のツール呼び出しで最終的にどの SandboxMode を使うかは、単一のソースで決まるのではなく、明確なフォールバック順序に従います。公式ドキュメントではこれを 3 層にまとめており、優先度の高い順から低い順に並べています:
| 優先度 | ソース | 説明 |
|---|---|---|
| 最高 | 承認済みの明示モード | 一度限りの権限昇格リトライ時に渡される mode で、セッションポリシーより優先される |
| 次点 | セッション最後の sandbox/mode イベント | セッションログとともに永続化され、リプレイで再構築できる |
| フォールバック | デプロイ既定モード | agent のない呼び出しと cwd のないセッションは、設定されたルートディレクトリを使用する |
層ごとに分解します。最優先は承認済みの明示モードです。シナリオは「一度限りの権限昇格リトライ」です。ある操作が既定ポリシーでブロックされ、ユーザーまたは上位層が今回だけ許可すると決め、mode を渡します。この mode はセッションレベルのポリシーを直接上書きします。なぜこれが最優先なのか。それは人間による明示的な決定であり、最も文脈があり、最も事情に通じた承認だからです。ただし注意すべきは、それが一度限りであり、セッションポリシーには書き戻されず、次回の同じ呼び出しは再び既定の軌道に戻るということです。これは前述の承認の「一度限りの決定」と同じ設計哲学です。
次はセッションの最後の sandbox/mode イベントです。セッション中にモード切り替えが発生することがあり、切り替えのたびに sandbox/mode イベントが生成され、最後のイベントが現在のセッションの有効なモードを決定します。重要な性質は、このイベントがセッションログとともに永続化され、リプレイで再構築できることです。つまり、モードはメモリ上の揮発性変数ではなく、セッション履歴の一部です。ログをリプレイして「その時点でこのセッションがどのモードだったか」を再構築でき、これは監査、問題の再現、インシデント分析にとって極めて重要です。上級読者向けに言えば、ここには暗黙の要件があります。モードを変更するあらゆるコードパスはイベントを発行すべきであり、変数をこっそり書き換えてはいけません。そうでなければリプレイが歪んでしまいます。
最下層はデプロイのデフォルトモードです。agent が呼び出しに関与していない場合、またはセッションに cwd がない場合、ポリシー解決は設定されたルートディレクトリとデフォルトモードにフォールバックします。この層はフォールバックであり、どの呼び出しにも少なくとも確定した起点があることを保証し、「利用可能なポリシーがない」状態を避けます。同時に workspaceRoot の値も決定することに注意してください。cwd のないセッションは設定されたルートディレクトリを使用します。
三つの層をまとめて見ると、ロジックは次のとおりです。単一の明示的認可 > セッションの現在状態 > グローバルデフォルト。この順序は直感的ですが、エンジニアリング上いくつかの落とし穴があります。
- 一回限りの mode をセッションレベルでキャッシュしない。権限昇格が安全なのは、それが永続化されないからです。一度キャッシュすると、セッションポリシーを黙って引き上げたのと同じです。
- モード変更は必ずイベントを発行する。プラグインが実行中にモードを切り替えられるなら、必ず sandbox/mode イベントを発行してください。そうでなければリプレイ再構築は誤った履歴になります。
- agent なしの呼び出しはデフォルト層を通ることを意識する。ツールが agent なしのシナリオをサポートするなら、デプロイのデフォルトモードが期待どおりか確認し、呼び出し元が必ずセッションを持つと仮定しないでください。
- resolve 時には正しい session を渡す。例の resolve({ session: exec.agent?.session }) は agent が欠けている場合に自然にフォールバック層へ落ちます。この ?. は適当に書かれたものではなく、「agent なしの呼び出し」という実在のパスに対応しています。
最初のコード例ですでに resolve の呼び出し形態を示しましたが、ここではより運用視点に寄った断片を補足し、解決されたポリシーフィールドをログとセルフチェックにどう使うかを示します。実運用でモードの由来を調査するのに役立ちます。
import type { Context } from '@deepseek-ai/cordis'
export const name = 'policy-inspector'
export const inject = ['sandbox', 'sandboxPolicy']
export function apply(ctx: Context) {
// ツール実行前に一度ポリシーを解決し、ログを出してセルフチェックする。
function inspect(session: unknown) {
const policy = ctx.sandboxPolicy.resolve({ session })
// モードとワークスペースルートは、以降のすべての境界判断の起点となる。
console.log('[policy] mode=%s workspaceRoot=%s', policy.mode, policy.workspaceRoot)
if (policy.mode === 'danger-full-access') {
// 全開でサンドボックスに入らない。ここでは監査のために明示的に痕跡を残す。
console.warn('[policy] sandbox bypassed: danger-full-access')
} else {
// 制限モード:確実に confine を通り、バックエンドが取得できることを確認する。
try {
const confined = ctx.sandbox.confine(['bash', '-c', 'true'], {
mode: policy.mode,
workspaceRoot: policy.workspaceRoot,
})
console.log('[policy] enforcement=%s', confined.enforcement)
if (confined.enforcement !== 'full') {
// partial は不十分とみなし、黙って続行せず上位に露出する。
throw new Error(`partial enforcement rejected: ${confined.enforcement}`)
}
} catch (err) {
// バックエンドがない場合 confine は SANDBOX_UNAVAILABLE を投げる。隔離なしの透過は許可しない。
console.error('[policy] confined probe failed', err)
throw err
}
}
return policy
}
// この inspect はツール実行の入口やヘルスチェックで呼び出せる。
void inspect
}
このコードは、本節のいくつかの中核概念を一度の観測可能なセルフチェックへとつなげている。ポリシーの解析、サンドボックスを通るかどうかの判定、enforcement の消費、バックエンドがない場合にエラーを表面化させること。これは実際のサンドボックス呼び出しを置き換えるものではないが、デプロイ時に「ポリシーから解析されたモードが期待どおりか」「バックエンドが本当に利用可能か」をすばやく確認できる。
workspaceRoot はどこから来るのか:不変な cwd の正規化と symlink/.. の実際のディレクトリ意味論
先ほどから繰り返しワークスペースルートに触れてきたが、ここでその由来をはっきりさせよう。通常のツール呼び出しは、呼び出しセッションの不変な cwd から workspaceRoot を導出する。「不変」という言葉に注意してほしい。cwd はセッションのレベルで固定されており、プロセスの実行中に chdir などの操作で変えられたりしない。これにより、同じセッション内のすべてのツール呼び出しが見る境界が一貫することが保証される。
導出された後、root は二段階の正規化を経なければならない:
- まずファイルシステムの意味論に従って正規化する:この段階では symlink を解決し、パスをファイルシステム上で実際に指している位置へ復元する。シンボリックリンクはここで「展開」される。
- 次に字句的正規化を行う:.. や . といった相対パス成分を処理し、パスを最も簡潔な形に畳み込む。
この順序は極めて重要であり、よくある落とし穴も説明してくれる:symlink/.. を含む cwd は、プロセスが実際に実行されているディレクトリを識別する。もし先に字句的正規化を行うと、a/b/.. は a に畳み込まれるが、b が別の場所を指す symlink であれば、ファイルシステム意味論ではまったく異なる位置である可能性がある。先にファイルシステム意味論に従って symlink を解決し、その後に .. を畳み込むことで、初めてプロセスが本当に存在するディレクトリが得られる。これが順序を逆にしてはならない理由である。
プラグイン作者への実際の影響は次のとおりである:
- 自分で workspaceRoot を組み立てないこと。これはポリシー解析の産物であり、自分で組み立てるとサンドボックスバックエンドの理解と食い違いやすく、「自分はワークスペース内だと思っているが、バックエンドはワークスペース外だと考えている」といった矛盾を招く。
- cwd について仮定を置かないこと。ツールのロジックが「現在のディレクトリはある既知のパスである」ことに依存しているなら、symlink を含むデプロイ環境ではおそらく失敗する。正規化された root を信頼せよ。
- cwd のないセッションは設定ルートディレクトリを使う。これはポリシーフォールバックの最後の層と一致しており、セッションのないシナリオでツールが得る workspaceRoot は、ある暗黙のデフォルトではなくデプロイ設定に由来することを意味する。
- リプレイ時に root は再構築できる。cwd が不変で、セッションイベントが永続化されているため、workspaceRoot はリプレイ中に再現可能であり、これは本番環境の問題を再現するうえで非常に重要である。
workspaceRoot を本節全体のセキュリティチェーンの中で見ると、それは「境界」という概念の物理的な着地点である。SandboxMode はどの種類のファイル操作を許可するかを決め、enforcement はバックエンドがどこまで実際に達成したかを報告し、workspaceRoot は書き込みがどの位置で起こり得るかを定義する。三者がそろって初めて、完全で監査可能なファイルシステム制約が構成される。
ここまでで、前半はサンドボックスという線を語り終えた。confine のインターフェース形態、三つのモードの境界、danger-full-access がなぜサンドボックスに入らないのか、enforcement の完全性の意味論、ポリシーの三層フォールバック、そして workspaceRoot の正規化の由来である。しかし危険な操作が通過しなければならない関門は二つあり、サンドボックスはそのうちの一つにすぎない。もう一つ——承認——は「この具体的な操作が許可されるかどうか」を決め、サンドボックスとは直交する認可の問題を扱い、デフォルトでフェイルクローズするための独自の仕組みを持っている。次の節では ctx.approval の承認フロー、ワンタイム決定の実装詳細、そしてサンドボックスと承認がどのように協調して完全な制約体系を構成するのかを展開する。
前回のセクションでは、ctx.sandbox と ctx.approval の役割分担、SandboxMode の段階的な意味論、そして三層のポリシーフォールバック順序を分解して説明し、enforcement における full と partial という 2 つのフィールド値の存在も確認しました。今回のセクションでは、もはや概念レベルにとどまらず、エラーコード、サンプルコード、意思決定分岐、受け入れチェックリストという 4 つの方向から、「このサンドボックスという檻が実際にどう実装されるのか」を直接補完します。中心となる結論はただ一言です。制限付きポリシー下では、静かな非隔離パススルーは決して合法ではない。そして consumer は partial enforcement に対して明示的に判断する責任を負わなければなりません。
SandboxUnavailableError と SANDBOX_UNAVAILABLE:利用可能なバックエンドがない場合の fail-closed
サンドボックスというサービスの最も核心的な設計前提は fail-closed(失敗時に閉じる)であり、fail-open(失敗時に通す)ではありません。この 2 つは言語レベルでは一語しか違いませんが、セキュリティ意味論では天と地ほどの差があります。fail-open は「不確かなら通す」を意味し、fail-closed は「不確かなら止める」を意味します。ホスト環境に利用可能なサンドボックスバックエンドがそもそも存在しない場合、システムは隔離がすでに有効であるかのように装って元の argv をそのまま spawn してはいけません。そうすることは、ポリシーが read-only または workspace-write を宣言しているにもかかわらず、密かに非隔離のコマンドを一度実行することに等しく、檻は形骸化します。
そのため、ctx.sandbox.confine(argv, policy) は、利用可能なバックエンドを 1 つも見つけられない場合に「劣化しているが利用可能」な結果を返すのではなく、直接 SandboxUnavailableError をスローします。このエラーオブジェクトが持つエラーコードは SANDBOX_UNAVAILABLE です。これを sentinel 値のようなものを返すのではなく例外として設計するのにはエンジニアリング上の考慮があります。呼び出し側は「戻り値を確認しない」ことで密かに隔離をスキップできません。なぜなら try/catch を書くか、エラーを上位へ伝播させる必要があるからです。これを無視しようとするいかなる書き方も、最も軽い結果はツール呼び出しの失敗であり、最悪の場合は上位の統合エラーハンドリング経路に捕捉されて記録されますが、いずれにせよ「非隔離実行の成功」になることはありません。
ここで特に強調すべきは、素材に繰り返し現れるあの一文です。制限付きポリシー下では、静かな非隔離パススルーは決して合法ではない。この文には 2 つの意味があります。第一の層は行動面です。read-only または workspace-write の下では、consumer は「サンドボックスが利用できない」からといって、たとえそれでツールが「正常に動いた」ように見えても、勝手に元の argv を直接 spawn する形へ変更しては決してなりません。第二の層はアーキテクチャ面です。制限付き実行経路は必ず ctx.sandbox.confine を通らなければならず、これが唯一の合法的な入口です。隔離を放棄することを自ら宣言している danger-full-access のようなモードだけが、ctx.sandbox を迂回して直接 spawn することを許されます。つまり、「隔離を迂回する」ことは、ポリシーに明示的に書かれた決定でなければならず、バックエンドの欠如によって暗黙的に発生する事故であってはならないのです。
エラーハンドリングの実践から見ると、consumer が SandboxUnavailableError に対して取る合理的な反応は通常 3 つあります。
- そのまま上位へスローする:上位のツールフレームワークにそれを失敗した呼び出しへ変換させ、エラーメッセージに SANDBOX_UNAVAILABLE エラーコードを残すことで、運用担当が「このマシンにはバックエンドが正しくインストールされていない」と特定しやすくします。
- 構造化エラーとして返すよう変換する:ツールフレームワークが execute に例外スローではなく構造化結果の返却を要求する場合、エラーコードと message を isError 構造に入れ、それでもいかなるコマンドも実行しません。
- 明示的に full-access へ切り替える:業務意味論が本当に許容し、かつ承認チェーンが同意した場合にのみ、モードを danger-full-access に変更してから直結 spawn を行えます。このとき意味論が変わっている点に注意してください。もはや「サンドボックス失敗時のフォールバック」ではなく、「承認済みの非隔離実行」です。
絶対に推奨してはならない書き方の一つが try { confine() } catch { spawn(argv) } です。このコードは表面上ツールを「より堅牢」に見せますが、実際には fail-closed を密かに fail-open へと変えており、サンドボックス機構において最も典型的かつ最も危険なアンチパターンです。もしあなたのコードベースに類似の構造が現れたなら、それはフォールトトレランスの強化ではなくセキュリティ上の欠陥として扱うべきです。
コードウォークスルー:sandboxed_echo ツールがどのようにポリシーを解決し、argv をラップし、そして spawn するか
以下では、素材にある my-plugins/sandbox-demo/src/index.ts の例を基に、「ポリシーの解決 → argv のラップ → spawn」という一連の流れを順に分解していきます。コードをそのままプロジェクトに貼り付けて実行できるようにするため、省略されていた spawn の出力収集部分を補完し、その他の構造は素材と一致させています。
// ファイルパス:my-plugins/sandbox-demo/src/index.ts
// サンドボックス化された consumer のデモ:まずポリシーを解決し、次に ctx.sandbox に argv をラップさせる。
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { spawn } from 'node:child_process'
export const name = 'sandbox-demo'
// このプラグインが依存する 3 つのサービスを宣言する:ツール登録、サンドボックスラップ、ポリシー解決。
export const inject = ['tools', 'sandbox', 'sandboxPolicy']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'sandboxed_echo',
description: 'Run echo inside the sandbox. The runoob demo command.',
parameters: {
text: { type: 'string', required: true, description: 'Text to echo' },
},
output: { schema: { type: 'string' } },
async execute(args, exec) {
// 1. 今回の呼び出しの完全なポリシーを解決する:セッションの cwd がワークスペースの境界となる。
const policy = ctx.sandboxPolicy.resolve({ session: exec.agent?.session })
// 2. consumer は正確な argv(プログラムと引数)を渡す。シェル文字列ではない。
const argv = ['bash', '-c', `echo ${JSON.stringify(args.text)}`]
// 3. danger-full-access は直接 spawn する。それ以外はサンドボックスラップに委ねる。
if (policy.mode === 'danger-full-access') {
const result = await runAndCollect(argv)
return result.stdout.trim()
}
// 4. 制限モード:confine は置き換えられた argv を返し、バックエンドがなければ SANDBOX_UNAVAILABLE をスローする。
const confined = ctx.sandbox.confine(argv, {
mode: policy.mode,
workspaceRoot: policy.workspaceRoot,
})
// 5. confined.enforcement に基づいて今回の実行を受け入れるかどうかを決定する。
if (confined.enforcement === 'partial' && policy.mode !== 'read-only') {
return {
isError: true,
error: { message: 'partial enforcement is not acceptable for runoob demo' },
}
}
const result = await runAndCollect(confined.argv)
return `confined echo ${args.text} (enforcement: ${confined.enforcement})`
},
}))
}
// 小さなユーティリティ:argv を spawn して stdout/stderr を収集し、一律に UTF-8 でデコードする。
function runAndCollect(argv: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> {
return new Promise((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8') })
child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8') })
child.on('error', reject)
child.on('close', (code) => resolve({ stdout, stderr, code }))
})
}
順を追って説明する。まず inject 配列:['tools', 'sandbox', 'sandboxPolicy']。この三つはどれ一つ欠かせない——tools はツールの登録に、sandbox は argv のラップに、sandboxPolicy は今回の呼び出しで使うべきモードとワークスペースルートの解決に使われる。依存関係を inject に書くということは、Cordis がアプリ起動段階でこれらのサービスが存在するかどうかをチェックするという意味であり、もしあるサービスがインストールされていなければ、プラグインは起動時にそのまま失敗し、実行時に ctx.sandbox が undefined だと気づくまで待つことはない。この「依存関係を事前に宣言する」やり方は、本質的に一種の fail-closed でもある。途中まで動いてから檻がちゃんと設置されていなかったと気づくより、起動できないほうがましだということだ。
第二のステップは ctx.sandboxPolicy.resolve({ session: exec.agent?.session })。括弧の中の引数が { session } であり、{ mode } でも、裸の session オブジェクトでもないことに注意してほしい。なぜ session を渡すのか? それはポリシーの解決が、素材に記述された三層のフォールバック順序に沿ってモードを決める必要があるからだ。最優先は「承認済みの明示的モード」、つまり一度限りの権限昇格リトライ時に明示的に渡された mode であり、これはセッションポリシーを上書きする。次には「セッションの最後の sandbox/mode イベント」で、こうしたイベントはセッションログとともに永続化されるため、リプレイして再構築できる。最後にようやく「デプロイのデフォルトモード」にフォールバックする。resolve が返す policy オブジェクトには、少なくとも mode と workspaceRoot の二つのフィールドが含まれる。通常のツール呼び出しは自分で workspaceRoot をでっち上げるのではなく、呼び出し元セッションの不変な cwd から導出する。この cwd はまずファイルシステムのセマンティクスに従って正規化され、さらに一度字句的正規化が行われるので、たとえパスに symlink や .. が含まれていても、最終的に得られるのはプロセスが実際に動作しているディレクトリであり、文字列レベルでそう見えるだけのディレクトリではない。
第三のステップは argv の形態の問題だ。例にははっきりとこう書かれている:
const argv = ['bash', '-c', `echo ${JSON.stringify(args.text)}`]
これが一つの配列であり、一本のシェル文字列ではないことに注意してほしい。素材はこの点を特に強調している。プロセスサンドボックスのモデルは「consumer が正確な argv を渡し、バックエンドがファイル効果ポリシーに従ってそれをラップする」というものであり、要点はシェル文字列ではなく正確な argvである。もしある consumer が本質的にシェルの形態で動作するなら、それも ['bash', '-c', command] の形を明示的に渡さなければならず、「シェルを起動したい」ということを argv にはっきりと書くべきであって、サンドボックスに文字列をどう分解すべきか推測させるべきではない。こうする利点は二重にある。一方でバックエンドは実行されるプログラム名と引数の境界を正確に把握できる。他方で、引数に現れる引用符、空白、リダイレクト記号が二重に解釈されることがなくなり、インジェクション系の問題を避けられる。
第四のステップは分岐処理だ。danger-full-access モードでは、consumer は生の argv を直接 spawn し、ctx.sandbox をまったく呼び出さない。これは「サンドボックスが失敗した後のフォールバック」ではなく、このモード自体が「隔離をバイパスする」という明示的な宣言である。素材はきっぱりと述べている。プロバイダに送られるのは read-only と workspace-write の二つのモードだけで、danger-full-access はそもそもサンドボックスに入らない。これにより、非常にエレガントなセキュリティ特性がもたらされる。制限付き実行は必然的に ctx.sandbox に到達するのであり、制限モードにおけるいかなる実行もこの入口を迂回できない。逆に、コードパスが ctx.sandbox を通らなければ、それは必然的に danger-full-access にあり、つまり必然的に明示的に認可された非隔離実行である。この二つのパスは互いに排他的であり、どちらも監査可能である。
第五ステップでは、制限付きブランチが ctx.sandbox.confine(argv, { mode, workspaceRoot }) を呼び出します。その戻り値の型は ConfinedArgv であり、資料によれば、この構造には「置換後の argv とバックエンドの強制実行の事実」が含まれます。つまり、consumer が受け取るのは元の argv ではなく、バックエンドがファイル効果ポリシーに基づいて加工した argv です。同時に enforcement フィールドも付随しており、バックエンドが実際にどの程度の隔離を達成したかを呼び出し側に伝えます。結果を取得した後、consumer は元の argv ではなく confined.argv を使って spawn します。ここにはよくある落とし穴があります。consumer が習慣で元の argv を spawn してしまうと、たとえ confine が呼ばれていても隔離は実際には有効になりません。正しいやり方は、常に confine が返した argv を spawn することです。
consumer が confined.enforcement に基づいて判断する方法:非 read-only では partial は直接エラーになる
資料の中のこの条件判断は、サンプル全体の中で最も繰り返し読む価値のある箇所です:
if (confined.enforcement === 'partial' && policy.mode !== 'read-only') {
return { isError: true, error: { message: 'partial enforcement is not acceptable for runoob demo' } }
}
これが表しているポリシーは、モードが read-only である場合に限り partial を受け入れ可能とし、workspace-write 以上のモードでは partial を一律に受け入れ不可として扱い、直接 isError を返すというものです。なぜこのように設計するのでしょうか。enforcement フィールドの意味は「バックエンドが実際に達成した強制実行の完全度」であり、値は二つだけです。'full' はバックエンドがそのモードで約束したすべてのファイル効果を制御したことを意味し、'partial' はその一部のみを制御したことを意味します。read-only の場合、約束する効果は主に「書き込みを拒否し、必要なデータ受け口(例えば /dev/null)のみを許可する」ことであり、たとえバックエンドが一部しか達成できなくても、残りのリスク面は比較的限定的で、通常そのような部分的な差異が書き込み能力をもたらすことはありません。しかし workspace-write の場合、約束するのは「ワークスペースルートおよびバックエンドが約束する一時領域の下での書き込みを許可する」ことです。いったん partial になると、ワークスペース境界が堅牢でない可能性があり、本来ブロックされるべき一部の書き込みパスが漏れ出す可能性があります。このときに partial を full として扱うのは自己欺瞞です。
ここで consumer に対して明確な行動基準を示す必要があります。絶対的な境界を要求する consumer は、partial を「不十分」として扱い、拒否するか、その差異を上位に明示しなければならないということです。資料の原文はまさにこの意味であり、サンプル内の書き方はその具体的な実装の一つです。つまり「拒否」を選び、partial を一度の失敗したツール呼び出しにしています。もう一つの準拠した方法は「上位に明示する」ことです。例えば enforcement の値をそのまま戻り値構造やログに含め、上位の承認者が「今回の実行では部分的な隔離しか得られなかった」ことを確認し、人間が許可するかどうかを判断できるようにします。どちらを選んでも、核心は partial を密かに full に偽装させないことです。
以下の表は、SandboxMode の三つの段階を「サンドボックスに入るか」「約束するファイル効果」「partial を受け入れ可能か」という三つの次元で並べて比較したもので、consumer を設計する際の参照に便利です:
| SandboxMode | ctx.sandbox に入るか | 約束されるファイルシステム効果 | enforcement = partial の場合の推奨処理 |
|---|---|---|---|
| read-only | はい | 必要なデータ受け口(/dev/null など)のみを許可し、書き込みは拒否する | 許容可能だが、partial をログに記録して後で確認できるようにすることを推奨 |
| workspace-write | はい | ワークスペースルートおよびバックエンドが約束する一時領域配下での書き込みを許可する | 許容不可。実行を拒否するか、その差異を上に公開すべき |
| danger-full-access | いいえ、生の argv を直接 spawn する | 分離の約束はなく、ファイル効果ポリシーを迂回する | 該当なし(confine は呼び出されない) |
混同しやすい点をもう一度明確にしておく必要があります。SandboxMode はファイルシステム効果のみを管理し、ネットワークやプロセスの可視性は含みません。つまり、read-only を選んだとしても、このモードがネットワークアクセスやプロセス一覧の可視性までまとめて管理してくれると誤解してはいけません。それらは約束されていません。脅威モデルに「Agent がこっそりネットワークリクエストを送る」や「Agent がホスト上の他のプロセスを探る」が含まれるなら、モードを1つ変えれば万事解決と期待するのではなく、サンドボックスの外に別の仕組みを用意する必要があります。モードの能力境界を明確に書くことは、モードを「完全に安全」と説明するよりもはるかに責任ある態度です。
defineTool の出力契約とサンドボックスの連携:output.schema、execute(args, exec)、exec.agent?.session
ツール定義側はほんのいくつかのフィールド宣言に見えますが、サンドボックス判断との間に直接的な結合関係があり、個別に切り出して説明する価値があります。例のツール定義に戻りましょう。
ctx.tools.register(defineTool({
name: 'sandboxed_echo',
description: 'Run echo inside the sandbox. The runoob demo command.',
parameters: {
text: { type: 'string', required: true, description: 'Text to echo' },
},
output: { schema: { type: 'string' } },
async execute(args, exec) { /* ... */ },
}))
まず parameters の required を見てください。素材例では text フィールドに required: true が付いており、このパラメータが必須であることを意味します。同時に description: 'Text to echo' も設定されています。required と description というこの2つの属性を軽く見てはいけません。それらは同時に2つの役割を担っています。1つ目はモデルに見せる契約説明であり、モデルはこのパラメータが何のためで、必ず与える必要があるかを知る必要があります。2つ目はツールフレームワークによる入力検証であり、必須フィールドが欠けた呼び出しは execute に入る前に遮断されます。sandboxed_echo にとって、text を必須にすることで「空入力によりコマンドに引数がなくなる」といった境界ケースを避けられます。
次に output.schema を見てみましょう。例では { schema: { type: 'string' } } と書かれており、つまりこのツールの戻り値は文字列であることが要求されています。これは execute 内部の return と対応している点に注意してください。正常系では文字列を返します(たとえば confined echo xxx (enforcement: full))。一方、partial 分岐ではオブジェクト { isError: true, error: { message: ... } } を返しています。これは、ツール結果の型が通常「成功値が output.schema に適合するか、統一された構造化エラーチャネルを通るか」という2つの経路をたどることを示しています。設計上の推奨は、サンドボックス関連の診断情報はできるだけエラー構造の中に入れ、output.schema が定める成功値に無理に押し込まないことです。enforcement のようなフィールドを成功文字列に混ぜてしまうと、その output.schema に依存する下流のあらゆる処理がこの余分なテキストの影響を受ける可能性があるからです。
最も重要な結合点は execute(args, exec) の第2引数です。例の最初のステップは次のとおりです。
const policy = ctx.sandboxPolicy.resolve({ session: exec.agent?.session })
なぜ他の場所ではなく exec.agent?.session から session を取得するのでしょうか。それは、ポリシー解決のフォールバックチェーンがセッションコンテキストを必要とするからです。セッションの最後の sandbox/mode イベントはセッションログとともに永続化されており、リプレイして再構築できます。一時的な権限昇格リトライ時に渡される明示的な mode が最優先されます。どちらも存在しない場合にのみ、デプロイのデフォルトモードへフォールバックします。session がなければ、この解決を完了できません。そして exec.agent?.session はオプショナルチェーンを使っており、session が欠けている可能性があることを意味します。
ここで留意すべきエンジニアリング上の詳細があります。素材はフォールバック順序の中で、「agent のない呼び出し」と「cwd のないセッション」は設定されたルートディレクトリを使用すると述べています。つまり、exec.agent?.session が undefined の場合、またはセッションに cwd がない場合、ポリシー解決はデプロイのデフォルトモードへフォールバックし、設定されたルートディレクトリを workspaceRoot として採用します。consumer にとって、これは設計上の問題を引き起こします。このフォールバックを受け入れるのか、それとも「session の欠如」を安全に実行できないものとして拒否するのか。ツールの意味論が書き込み操作に関わるなら、session の存在を明示的に要求することを推奨します。読み取り専用のプローブにすぎないなら、デプロイのデフォルトモードへのフォールバックを受け入れて問題ありません。判断の根拠は、やはりコードを書くのが楽かどうかではなく、あなたの脅威モデルです。
上記の3点をつなげると、「ツール定義とサンドボックスの連携」は次のフィールドレベルの対照表にまとめられます。
| フィールド / パラメータ | 役割 | サンドボックス決定との関係 |
|---|---|---|
| parameters.text.required | 入力パラメータが必須であることを宣言し、フレームワークが execute に入る前に検証する | 空入力によるコマンド形態の異常を避け、制限モードでの予期しない動作を間接的に減らす |
| parameters.text.description | モデルにパラメータの意味論を示す | モデルが期待に沿った argv 内容を構築するのを助ける |
| output.schema | 成功戻り値の型(ここでは string)を制約する | サンドボックス診断情報はエラー構造を通すべきで、成功値を汚染しない |
| execute 第2引数 exec | 実行時コンテキストを運ぶ | exec.agent?.session はポリシー解決フォールバックチェーンの入力である |
サンドボックスと承認の責務分担:誰がプロセスが触れられるファイルを画定し、誰が今回を通すかどうかを決めるのか
この仕組みに触れたばかりの多くの開発者は、サンドボックスと承認を混同して使ってしまい、さらには承認をサンドボックスの受け皿のように扱ってしまう。素材はこの二つのサービスの位置づけを非常に明確に線引きしており、原文の意味をそのまま言い直す価値がある:サンドボックスは「プロセスがどのファイルに触れられるか」を囲い込み、承認は「今回の操作を通すかどうか」を応答者に委ねて決めさせる。言い換えれば、サンドボックスが解決するのは「コマンドがどんな境界の中で動くか」であり、承認が解決するのは「この具体的な操作が許可されるかどうか」である。一方は空間的な制約であり、もう一方はイベント的な意思決定である。
時間軸の観点から見ると、両者の介入点も異なる。サンドボックスはコマンドがまさに実行されようとする瞬間に、ファイル効果ポリシーに従って argv をラップする——これは制限付き実行のたびにその前で起こり、受動的かつ必然的である。一方、承認はあるリスクのある操作が提案されたときに、応答者による一回限りの意思決定に委ねられる:今回通すか拒否するか。素材はこれを「一回限りの意思決定」と呼んでおり、この言葉は重要である。つまり承認の結論は、後続の同種の操作に対する普遍的な許可を構成しない;次の操作は依然として改めて判断する必要がある。
では、なぜこの二つの仕組みを同時に持つのか。それは、両者が異なる形態のリスクを防ぐからである。サンドボックスが防ぐのは「コマンドが一度動き出したら、境界を越えて触れるべきでないファイルに触れてしまわないか」——たとえそのコマンド自体が承認を経ていても、サンドボックスは動き出した後の行動範囲が制限されることを依然として保証しなければならない。承認が防ぐのは「この操作はそもそも許可されるべきではなかった」——たとえサンドボックスがリスクを小さく囲い込めても、ある種の操作自体は人が判断すべきである。両者を重ねて初めて、実行境界と実行意欲の両方を制限できる。
もう一つ強調しなければならない共通点がある:両者ともデフォルトでフェイルクローズドである。サンドボックスのフェイルクローズドは、先に述べた SandboxUnavailableError と SANDBOX_UNAVAILABLE として現れる;承認のフェイルクローズドは、「明確な許可がなければ実行できない」という形で現れる。この二つの fail-closed を重ねることで、仕組み全体の中核的なセキュリティ特性が得られる:Agent がリスクのあることをしようとするなら、承認という門をくぐる必要があり、かつサンドボックスが与えた境界内でしか活動できない;どの一环でも確定的な「可」が得られなければ、実行は起こらない。
以下では、二つのサービスの責務、トリガーのタイミング、失敗モードを並べて比較し、新しいツールを書くときに「この要件はどちら側に落ちるべきか」を素早く見極められるようにする:
| 観点 | ctx.sandbox | ctx.approval |
|---|---|---|
| 解決する問題 | コマンドがどんな境界の中で動くか(プロセスがどのファイルに触れられるか) | この具体的な操作が許可されるかどうか |
| 意思決定者 | バックエンドがファイル効果ポリシーに従って自動的に argv をラップする | 応答者が一回限りの意思決定を行う |
| トリガーのタイミング | 制限付き実行の毎回の spawn の前 | リスクのある操作が提案されたとき |
| フェイルクローズドの現れ方 | SandboxUnavailableError(SANDBOX_UNAVAILABLE)を投げる | 明確な許可がなければ実行しない |
| 典型的な誤用 | confine 失敗後にそのまま元の argv を spawn する | 一度の許可を同種の操作に対する普遍的な許可とみなす |
制限モードにおける調査チェックリスト:policy.mode から enforcement までの項目別確認パス
サンドボックス化されたツールが実環境で異常な挙動を示すとき——たとえば、本来ワークスペースに書き込むべきコマンドが拒否されたり、本来拒否されるべき操作がなぜか通ってしまったりする場合——最も効果的なのは、場当たり的にコードを変更することではなく、決まったパスに沿って項目ごとに確認することです。以下の順序は素材内の各フィールドとエラーコードに対応しており、そのまま調査チェックリストとして使えます。
- policy.mode が制限ティアに該当するか確認する。最初のステップは、
policy.modeがread-only、workspace-write、danger-full-accessのいずれであるかを確認することです。なぜなら、すでに danger-full-access であれば、制限付き実行パスはそもそも発動しないため、調査の方向はサンドボックスを引き続き調べることではなく、「なぜこのセッションが full-access として解決されたのか」に切り替えるべきだからです。よくある原因は、セッションログ内の明示的な権限昇格リトライが高優先度モードを植え付けたこと、あるいはデプロイのデフォルトモード自体が full-access であることです。 - confine が SANDBOX_UNAVAILABLE をスローするか確認する。ツールのエラーに SandboxUnavailableError またはエラーコード SANDBOX_UNAVAILABLE が含まれている場合、その時点でホスト環境に利用可能なサンドボックスバックエンドがなかったことを意味します。この場合の正しい対処は、バックエンドをインストールまたは修復するか、業務上許容され承認を得た前提で明示的に danger-full-access に切り替えることです。絶対に catch を追加してそのまま元の argv を spawn してはいけません。
- confined.enforcement が full か確認する。実行はできるが結果が期待と異なる場合は、enforcement の値を確認してください。素材で示されている 2 つの partial のケースは、古い Landlock ABI と、Windows ACL runner の Everyone およびハードリンク境界です。あなたのツールが「絶対的な境界」に依存しているなら、partial に遭遇した場合は例に示された書き方に従って実行を拒否するか、この違いを上位に露出させるべきです。
- workspaceRoot の正規化結果が期待どおりか確認する。workspaceRoot はセッションの不変な cwd から派生します。まずファイルシステムのセマンティクスに従って正規化し、次に字句的正規化を行うため、symlink や
..を含む cwd はプロセスが実際に実行されているディレクトリを正しく識別します。本来書き込むべきでない場所への書き込みがコマンドに許可されたことに気づいたら、まず最終的な workspaceRoot を出力して、それが自分が想定しているディレクトリかどうかを確認してください——symlink のシナリオでは「ワークスペース内に見えるが、正規化後は実は別の場所にある」という状況が非常に起こりやすいです。
このチェックリストを一文に圧縮すると:まずモードを見て、次にバックエンドの可用性を見て、次に enforcement を見て、最後に境界解決を見る。4 つのステップのうちどれか 1 つで問題を特定できれば、それ以上調べる必要はありません。逆に、4 つのステップすべてが通過しても問題が残る場合、問題はサンドボックス側ではなく、承認側またはツール自身の argv 構築ロジックに向かうべきである可能性が高いです。
ここで頻出するエンジニアリングの落とし穴を 2 つとその解決策を補足します。1 つ目の落とし穴は「argv に shell 構文が含まれているのに明示的に shell を起動していない」:誰かが直接 ['echo', args.text] と書きながら、text にリダイレクト記号を入れてしまい、まったく解釈されず、コマンドの挙動が期待と一致しないというものです。素材の原則に従えば、shell 形態の consumer は明示的に ['bash', '-c', command] を渡さなければならないので、素直に argv 配列に分割するか、bash を起動することを明示的に書くかのどちらかです。2 つ目の落とし穴は「ConfinedArgv を取得した後も元の argv を spawn してしまう」:隔離が実際には効いていないのに、コードは完全に正しく見えるというものです。解決策は runConfined(confined) ヘルパー関数を統一的にカプセル化し、すべての制限付き実行が ConfinedArgv を渡さなければならないようにして、型のレベルで誤用を塞ぐことです。
2026年9月時点の最新実践:partial enforcement を一等市民として扱う
時間を2026年9月に進めると、現在のこの仕組みが実践の中で進化させてきた最も重要な合意は、enforcement をもはや無視してよい付随フィールドとして扱うのではなく、partial enforcement を一等市民として扱うことです。「一等市民」とは、それが独立した型スロット、独立した分岐処理、独立したログと受け入れ項目を持ち、何らかの default case に押し込まれて密かに見逃されるものではない、という意味です。
消費側に具体的に言えば、2つの実装要件があります。第一に、full と partial を明示的に区別することです。if (confined.enforcement !== 'full') { /* 何もしない */ } のような形を書くのではなく、2つの値をそれぞれ処理し、それぞれの値に明確な行き先を持たせるべきです。full は通常どおり実行し、partial は以下の第二項に従って処理します。例にある if (confined.enforcement === 'partial' && policy.mode !== 'read-only') は典型的な明示的分岐であり、まったく曖昧さなく読めます。partial が許容されるのは read-only の下だけです。
第二に、partial を拒否するか、その区別を上位へ露出することです。素材の原文は「絶対的な境界を要求する consumer は partial を『不十分』として扱い、拒否するか、この区別を上位へ露出しなければならない」です。この文は2つの準拠オプションを与えています。「拒否」を選ぶことは isError を直接返し、この呼び出しを失敗させることを意味します。「上位へ露出」を選ぶことは enforcement の値をそのまま上位層へ運び、承認者や運用担当者が「今回の実行は部分的な隔離しか得られなかった」と見られるようにすることを意味します。どちらでもよく、重要なのは partial を黙って full に退化させないことです。
受け入れのレベルでは、2026年9月の実践はすでに2つの具体的な partial 状況をまれな辺角ケースではなく通常の受け入れ項目に組み込んでいます。Windows ACL runner の Everyone とハードリンク境界、そして古い Landlock ABI です。これは、CI やリリースチェックにおいて、これら2種類の環境に対してサンドボックス化ツールを専用に実行し、partial シナリオ下での consumer の挙動が期待どおりであること——きれいに拒否するか、明確に露出するか——を確認すべきことを意味します。これら2つのシナリオを本番で初めて遭遇するまで残しておくことは、最も典型的な受け入れの見落としです。
最後に、read-only と partial の組み合わせの意味を再確認します。例の書き方に従えば、read-only の下では partial は許容されます。しかしこれは、read-only が enforcement をまったく気にしなくてよいという意味ではありません。partial は依然としてログに記録することが推奨されます。理由は、read-only はファイルシステム効果のみを管理し、それ自体にはネットワークとプロセスの可視性が含まれないからです。もしあなたがネットワークに敏感な環境にいるなら、read-only の partial 記録は、ある実行が想定外のバックエンドに落ちたかどうかを判断する助けとなり、その後のより厳格なポリシー調整の根拠を提供します。
まとめとベストプラクティス
全文——前段のサンドボックス決定と承認フロー、およびこの段のエラーコード、コードウォークスルー、enforcement 決定、2026年9月の実践——を、そのまま実行できるチェックリストに圧縮します。
- 制限付き実行は必ず ctx.sandbox.confine を経由しなければならない。read-only と workspace-write の2つのモードは必然的にサンドボックス入口に到達し、制限付きポリシー下での静かな非隔離パススルーは決して合法ではない。danger-full-access のみが ctx.sandbox を迂回して生の argv を直接 spawn することを許されます。
- SandboxUnavailableError と SANDBOX_UNAVAILABLE をハード失敗として扱う。利用可能なバックエンドがない場合、confine はこのエラーを投げます。「catch してから生の argv を直接 spawn する」ような fail-closed を fail-open に変えるコードは決して書かないでください。
- 常に正確な argv を渡すこと。shell 文字列を渡してはいけません。shell が必要な場合は明示的に
['bash', '-c', command]と書きます。そして常に confine が返すconfined.argvを spawn し、元の配列ではありません。 - ctx.sandboxPolicy.resolve({ session }) で今回の完全なポリシーを解決すること。セッションログに永続化された sandbox/mode イベントとデプロイ既定モードのフォールバックチェーンに依存します。セッションコンテキストは exec.agent?.session から取得し、agent のない呼び出しと cwd のないセッションはデプロイ既定モードと設定ルートディレクトリにフォールバックすることに注意してください。
- partial を一等市民として扱う。
if (confined.enforcement === 'partial' && policy.mode !== 'read-only')のような明示的分岐で処理し、read-only 以外の partial は一律に拒否するか上位へ露出し、決して full に偽装させないこと。 - SandboxMode はファイルシステム効果のみを管理することを覚えておくこと。ネットワーク隔離とプロセスの可視性を約束しません。ネットワークとプロセスに関連する脅威には、サンドボックスの外で別途仕組みを設計する必要があります。
- サンドボックスと承認の責務境界を揃えること。サンドボックスはプロセスがどのファイルに触れられるかを画定し、承認は今回許可するかどうかを決定し、両者とも既定で fail-closed です。承認の許可は一度限りの決定であり、後続の同種操作に対する一般的な権限付与を構成しません。
- partial シナリオを通常の受け入れに組み込むこと。古い Landlock ABI、Windows ACL runner の Everyone とハードリンク境界には、それぞれ対応するテストケースを用意し、partial 下での consumer の拒否または露出の挙動を受け入れるべきです。
- トラブルシューティングは固定の順序で進めること。policy.mode が制限付きティアに入るか → confine が SANDBOX_UNAVAILABLE を投げるか → confined.enforcement が full か → workspaceRoot の正規化結果が期待どおりか。
- ツール定義とサンドボックスを対応させること。parameters.required と description はモデルが正しい入力を作るのを助け、output.schema は成功値を制約し、サンドボックスの診断情報は成功戻り値を汚染するのではなく構造化エラーを通します。
この記事をコードレビューにそのまま貼れる一文に凝縮するなら、こうなる:制限された実行は必ずサンドボックスの入口を通らせ、隔離が不十分な場合はすべて明示的に可視化し、隔離を回避する場合はすべて承認の痕跡を残す。この三点を実現して初めて、Agent の危険な動作は本当に檻の中に閉じ込められたことになる。