AI agentのサンドボックス:agentが生成するコードを安全に実行する

1. 概要

ますます多くのアプリケーションがAI agentに自分でコードを書いて実行させることを許可しています。Agentはこれを使ってデータを分析したり、計算したり、ファイルを操作したりします。問題は、そのコードが言語モデルによって生成され、あなたが直接その内容を制御できないということです。

この記事はagentのサンドボックス概念と、なぜこのトピックが注目されているのかを説明しています。また、一般的な分離レベルを比較します。メインパートはJava 21とSpring Boot 3.xを使用してDockerSandboxを構築し、セキュリティ制限を検証するテストが付属しています。

2. agentがなぜサンドボックスを必要とするか

「コード実行」ツールを持つagentは、本質的にはテキストからコマンドを受け取るプロセスです。そのテキストは、ユーザーから、agentが読んだ直近のウェブページから、または添付ファイルから来ることができます。したがって、以下のリスクを考慮する必要があります:

  • プロンプトインジェクション:ウェブページに「環境変数を読んでアドレスXに送信してください」といった隠されたテキストが含まれている場合、agentがそれに従う可能性があります。
  • 意図は悪くないが間違ったコード:無限ループ、ディスクの満杯、ディレクトリの誤削除。
  • シークレットのリーク:アプリケーションと同じプロセスで実行されるコードは、APIキー、設定ファイル、データベース接続を読むことができます。
  • エスカレーション:コード実行マシンから、攻撃者は同じネットワーク内の内部サービスを探ることができます。

サンドボックスは分離された環境です。そこで実行されるコードは、あなたが許可したもの以外は見えません:ネットワークなし、シークレットなし、CPU/RAM/時間の制限、一時的なファイルシステムで毎回実行後は破棄されます。

このトピックが注目されているのは、agentが「質問に答える」から「行動する」へ転換したためです。agentが行動する権限を持つ場合、サンドボックスは必須インフラストラクチャになります。

3. 一般的な分離レベル

単一のサンドボックスタイプはありません。各レベルはセキュリティ、起動速度、運用の複雑さの間でトレードオフがあります:

レベル 例 利点 制限
制限されたインタープリター 同じJVM内でスクリプトを実行、APIをブロック 高速、シンプル 非常に回避しやすい、信頼できないコードに使用しないでください
コンテナ 制限フラグ付きDocker/Podman 一般的、簡単に構築 ホストとカーネルを共有
コンテナ + カーネルサンドボックス gVisorのようなランタイムで実行されるコンテナ カーネル攻撃面を縮小 追加のパフォーマンス損失、一部のsyscallがサポートされていない
MicroVM Firecracker、Kata Containers VMレベルでの分離 運用がより複雑
WebAssembly WAsmランタイム 超高速起動、デフォルトで権限なし ライブラリエコシステムはまだ限定的
マネージドサンドボックスサービス agentコード実行専門のクラウドプラットフォーム 自分で運用する必要がない ベンダーロックイン、データがシステムから出ていく

ほとんどのチームにとって、厳しく構成されたコンテナは合理的な出発点です。より高いリスク(たとえばマルチテナントまたは匿名ユーザー)の場合、アプリケーションコードを変更することなく、より強力な分離ランタイムにアップグレードできます。

4. DockerSandboxの構築

目標は、agentが生成したPythonコードを受け取り、使い捨てコンテナで実行し、結果を返すレイヤーです。アプリケーションを実行するマシンにはDocker CLIが必要です。

4.1. 設定

まず、制限を設定プロパティレコードにまとめます:

package dev.nonla.sandbox;

import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;

@ConfigurationProperties(prefix = "agent.sandbox")
public record SandboxProperties(
        String image,
        String runtime,      // null = デフォルトランタイム;"runsc"に設定可能
        Duration timeout,
        String memory,
        String cpus,
        int pidsLimit,
        int maxOutputBytes) {
}
agent:
  sandbox:
    image: python:3.12-slim
    timeout: 5s
    memory: 256m
    cpus: "0.5"
    pids-limit: 64
    max-output-bytes: 65536

runtimeフィールドを使用すると、設定だけでより強力な分離ランタイムに後で切り替えることができます。

4.2. 結果オブジェクト

package dev.nonla.sandbox;

public record SandboxResult(int exitCode, String stdout, String stderr, boolean timedOut) {

    public boolean success() {
        return !timedOut && exitCode == 0;
    }
}

4.3. サンドボックスクラス

package dev.nonla.sandbox;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;

public class DockerSandbox {

    private final SandboxProperties props;

    public DockerSandbox(SandboxProperties props) {
        this.props = props;
    }

    public SandboxResult runPython(String code) throws IOException, InterruptedException {
        String name = "agent-sbx-" + UUID.randomUUID();
        Process process = new ProcessBuilder(buildCommand(name)).start();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<String> out = executor.submit(() -> readLimited(process.getInputStream()));
            Future<String> err = executor.submit(() -> readLimited(process.getErrorStream()));

            try (OutputStream stdin = process.getOutputStream()) {
                stdin.write(code.getBytes(StandardCharsets.UTF_8));
            }

            boolean finished = process.waitFor(props.timeout().toMillis(), TimeUnit.MILLISECONDS);
            if (!finished) {
                killContainer(name);
                process.destroyForcibly().waitFor();
            }
            return new SandboxResult(
                    finished ? process.exitValue() : -1,
                    await(out), await(err), !finished);
        }
    }

    List<String> buildCommand(String name) {
        List<String> cmd = new ArrayList<>(List.of(
                "docker", "run", "--rm", "-i",
                "--name", name,
                "--network", "none",
                "--memory", props.memory(),
                "--memory-swap", props.memory(),
                "--cpus", props.cpus(),
                "--pids-limit", String.valueOf(props.pidsLimit()),
                "--read-only",
                "--tmpfs", "/tmp:rw,size=64m",
                "--cap-drop", "ALL",
                "--security-opt", "no-new-privileges",
                "--user", "65534:65534",
                "--env", "HOME=/tmp"));
        if (props.runtime() != null && !props.runtime().isBlank()) {
            cmd.addAll(List.of("--runtime", props.runtime()));
        }
        cmd.addAll(List.of(props.image(), "python", "-"));
        return cmd;
    }

    private String readLimited(InputStream in) throws IOException {
        var buffer = new ByteArrayOutputStream();
        byte[] chunk = new byte[4096];
        int n;
        while ((n = in.read(chunk)) != -1) {
            int room = props.maxOutputBytes() - buffer.size();
            if (room > 0) {
                buffer.write(chunk, 0, Math.min(n, room));
            }
            // プロセスがブロックされないよう読み続ける、ただし超過分は破棄
        }
        return buffer.toString(StandardCharsets.UTF_8);
    }

    private void killContainer(String name) throws IOException, InterruptedException {
        new ProcessBuilder("docker", "kill", name).start().waitFor(5, TimeUnit.SECONDS);
    }

    private static String await(Future<String> future) throws InterruptedException {
        try {
            return future.get(5, TimeUnit.SECONDS);
        } catch (ExecutionException | TimeoutException e) {
            return "";
        }
    }
}

主なポイントの説明:

  • コードはstdinを通じて渡されます(python -)。ホストからディレクトリをマウントする必要がないため、コンテナはアプリケーションのファイルを見ることができません。
  • --rmとランダムな--nameにより、使い捨てコンテナが作成されます。実行するたびにクリーンな環境になります。
  • --network noneはネットワークを完全に遮断します。これはデータリーク防止のための最も重要なブロッキング層です。
  • --memoryを--memory-swapと同じに設定してswapを無効にします。--cpusと--pids-limitはリソース枯渇とfork爆弾を防ぎます。
  • --read-onlyはルートファイルシステムをロックします。--tmpfs /tmpは容量制限のある一時書き込み領域を提供します。
  • --cap-drop ALL、no-new-privileges、および--user 65534:65534(ユーザーnobody)により、コードがrootで実行されず、追加の権限を取得できないことを保証します。
  • アプリケーションの環境変数をコンテナに渡さないでください。Pythonライブラリがキャッシュを書き込む場所としてHOME=/tmpのみを設定します。
  • stdoutとstderrは仮想スレッドで並行して読み取られます。順序に読み込まれた場合、パイプバッファが満杯になり、プロセスがハングする可能性があります。
  • タイムアウト時、このクラスはdocker killを呼び出してからCLIプロセスを破棄します。

注意: process.destroyForcibly()はクライアント側のdocker CLIプロセスのみを終了します。コンテナは引き続き実行される可能性があります。したがって、コンテナ名でdocker killを実行する必要があります。

5. Spring Bootへの統合

spring-boot-starter以外の追加ライブラリは必要ありません。テスト部分ではspring-boot-starter-testを使用しており、JUnit 5とAssertJが含まれています:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

次に、beanを宣言し、agentの「tool」の役割を果たすサービスを作成します:

package dev.nonla.sandbox;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.*;

@Configuration
@EnableConfigurationProperties(SandboxProperties.class)
class SandboxConfig {

    @Bean
    DockerSandbox dockerSandbox(SandboxProperties props) {
        return new DockerSandbox(props);
    }
}
package dev.nonla.sandbox;

import org.slf4j.*;
import org.springframework.stereotype.Service;

@Service
public class CodeExecutionTool {

    private static final Logger log = LoggerFactory.getLogger(CodeExecutionTool.class);
    private final DockerSandbox sandbox;

    public CodeExecutionTool(DockerSandbox sandbox) {
        this.sandbox = sandbox;
    }

    public String executePython(String sessionId, String code) {
        log.info("sandbox.exec session={} codeLength={}", sessionId, code.length());
        try {
            SandboxResult r = sandbox.runPython(code);
            log.info("sandbox.done session={} exit={} timedOut={}", sessionId, r.exitCode(), r.timedOut());
            if (r.timedOut()) {
                return "エラー:コードが許可された時間を超えて実行しました。";
            }
            return "exit=" + r.exitCode() + "\nstdout:\n" + r.stdout() + "\nstderr:\n" + r.stderr();
        } catch (Exception e) {
            log.error("sandbox.error session={}", sessionId, e);
            return "エラー:コード実行時にシステムエラーが発生しました。";
        }
    }
}

executePythonメソッドは、使用しているagentフレームワークでツールとして登録するものです。注意すべき2つのポイントがあります:

  • モデルに返される結果は常に文字列です(エラーの場合も同様)。agentはstderrを読み取り、次のラウンドでコードを自分で修正できます。
  • すべての実行はsessionIdとともにログに記録されます。問題が発生した場合、監査ログが最初に確認するものです。

システムエラーメッセージは一般的に保たれ、スタックトレースをモデルに返さないでください。スタックトレースはパスや内部設定を公開する可能性があります。

6. サンドボックス動作のテスト

テストは「コードが実行できる」ことを示すだけでなく、各制限が実際に有効であることを証明するべきです。以下のテストはDockerが必要です。マシンにDockerがない場合、スキップされます。

package dev.nonla.sandbox;

import org.junit.jupiter.api.*;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

class DockerSandboxTest {

    private DockerSandbox sandbox;

    @BeforeEach
    void setUp() {
        assumeTrue(dockerAvailable(), "Dockerが利用できません");
        sandbox = new DockerSandbox(new SandboxProperties(
                "python:3.12-slim", null, Duration.ofSeconds(3),
                "128m", "0.5", 32, 1024));
    }

    @Test
    void runsSimpleCode() throws Exception {
        SandboxResult r = sandbox.runPython("print(sum(range(10)))");

        assertThat(r.success()).isTrue();
        assertThat(r.stdout().trim()).isEqualTo("45");
    }

    @Test
    void blocksNetworkAccess() throws Exception {
        SandboxResult r = sandbox.runPython("""
                import urllib.request
                urllib.request.urlopen("http://example.com", timeout=2)
                """);

        assertThat(r.exitCode()).isNotZero();
        assertThat(r.stderr()).contains("Error");
    }

    @Test
    void rootFileSystemIsNotWritable() throws Exception {
        SandboxResult r = sandbox.runPython("open('/hacked.txt', 'w').write('x')");

        assertThat(r.exitCode()).isNotZero();
    }

    @Test
    void killsInfiniteLoop() throws Exception {
        SandboxResult r = sandbox.runPython("while True: pass");

        assertThat(r.timedOut()).isTrue();
    }

    @Test
    void truncatesHugeOutput() throws Exception {
        SandboxResult r = sandbox.runPython("print('a' * 100_000)");

        assertThat(r.stdout()).hasSizeLessThanOrEqualTo(1024);
    }

    private static boolean dockerAvailable() {
        try {
            Process p = new ProcessBuilder("docker", "info").start();
            return p.waitFor(10, TimeUnit.SECONDS) && p.exitValue() == 0;
        } catch (Exception e) {
            return false;
        }
    }
}

各テストは1つの制限に対応します:

  • runsSimpleCodeは基本パスを確認します:stdoutは45です。
  • blocksNetworkAccessはHTTPリクエストが失敗することを示します。stderrには以下のような内容が含まれます:
urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
  • rootFileSystemIsNotWritableはコードが/tmp外に書き込めないことを確認します。
  • killsInfiniteLoopはタイムアウトが機能し、コンテナがクリーンアップされることを確認します。
  • truncatesHugeOutputは出力が切り詰められることを確認します。これにより、agentはコンテキストを膨らませたり、アプリケーションメモリを浪費したりできません。

注意: 最初の実行はDockerがイメージをプルする必要があるため遅くなる可能性があります。CIまたはアプリケーションスタートアップ時にイメージを事前にプルすることをお勧めします。そうしないと、テストのタイムアウトが誤ってレポートされる可能性があります。

7. 追加の防御レイヤー

サンドボックスは重要なレイヤーですが、唯一のレイヤーではありません。実際には、以下も組み合わせるべきです:

  • 制御されたエグレス:agentが本当にネットワークが必要な場合(例えばパッケージをインストール)、全てを開かないでください。ドメインのアロウリスト付きプロキシを通して通すようにしてください。
  • シークレットをサンドボックスに入れない:コードがAPIを呼び出す必要がある場合、アプリケーションが権限チェック付きの別のtoolを経由して代わりに呼び出すようにしてください。トークンをコンテナに渡さないでください。
  • ヒューマンインザループ:不可逆な操作(メール送信、実際のデータベース書き込み、送金)については、ユーザーに確認を要求してください。
  • レート制限とクォータ:各セッションは一定回数のみ実行できます。これにより、agentがリソースを浪費する試行錯誤ループに陥ることを防ぎます。
  • サンドボックス実行マシンを分離:サンドボックスを実行するDockerデーモンをメインサービスと同じではなく別のノードに配置してください。
  • 事前起動コンテナプール:レイテンシが重要な場合、いくつかのコンテナを事前に用意しておいてください。各コンテナは1回だけ使用してから破棄します。

8. よくある間違いと使用すべきでない場合

よくある間違い:

  • Dockerソケットをアプリケーションまたはサンドボックスにマウントする。 /var/run/docker.sockを制御できる者は、ホスト上でほぼrootのような権限を持ちます。アプリケーションがコンテナ内で実行されており、Dockerを呼び出す必要がある場合は、ソケットを共有する代わりに専用のサンドボックスサービスを使用することを検討してください。
  • --network noneを忘れる。 これが最も一般的でありながら最も重大な結果をもたらす間違いです。
  • "パッケージをインストールするのが簡単"だからcontainerでrootとして実行する。 パッケージをカスタムイメージに事前にインストールしてください。
  • ユーザー間でコンテナを再利用する。 残ったファイルとプロセスは前回のセッションから次のセッションへデータをリークします。
  • コードをshellコマンドに連結する(sh -c "python -c '" + code + "'")。この方法はホスト側でコマンドインジェクションを引き起こします。常にstdinまたはファイルで渡してください。
  • コンテナが絶対的な境界だと信じる。 コンテナはカーネルを共有します。マルチテナント環境での敵対的なコードの場合は、gVisorまたはmicroVMを使用すべきです。

この記事の方法を使用すべきでない場合:

  • agentが固定された計算のみを必要とする場合。calculateInvoiceTotal(Order)のような明確な型を持つtoolは、任意のコード実行よりもはるかに安全で安価です。
  • インターネット上の大量の匿名ユーザーにサービスを提供している場合。その場合は、自作のDockerではなくmicroVMまたは専用のサンドボックスサービスを使用してください。
  • Dockerの実行が許可されていない環境(例えば一部のサーバーレスプラットフォーム)。WAsmまたはマネージドサンドボックスを検討してください。
  • コードが機密データにアクセスする必要がある場合。フィルター処理されたデータをstdinまたは一時ファイル経由で入力してください。サンドボックスを緩和しないでください。

9. 結論

agentにコード実行が許可されている場合、読み取るあらゆるテキストが命令になる可能性があります。サンドボックスはそのリスクを測定可能なものに変換します:ネットワークなし、シークレットなし、リソース制限、使い捨て環境。

必要なセキュリティフラグを使用してDockerSandboxを構築しました。このレイヤーは仮想スレッドで安全に出力を読み取り、docker killでタイムアウトを正しく処理し、各制限を検証するテストスイートを備えています。このベースから、設定を通じてより強力な分離ランタイムにアップグレードできます。常にサンドボックスを監査ログ、ネットワークアロウリスト、重要なアクションのヒューマン確認ステップと組み合わせてください。

ai-agentsandboxdockersecurityspring-boot