Sandbox cho AI agent: chạy code của agent an toàn

1. Tổng quan

Ngày càng nhiều ứng dụng cho phép AI agent tự viết code rồi chạy luôn. Agent dùng việc này để phân tích dữ liệu, tính toán hoặc thao tác file. Vấn đề là code đó do một mô hình ngôn ngữ sinh ra, và bạn không kiểm soát trực tiếp được nội dung của nó.

Bài viết giải thích khái niệm sandbox cho agent và vì sao chủ đề này đang được quan tâm. Bài cũng so sánh các mức cô lập thường gặp. Phần chính là xây dựng một DockerSandbox bằng Java 21 và Spring Boot 3.x, kèm test kiểm chứng các giới hạn an toàn.

2. Vì sao agent cần sandbox

Một agent có công cụ “chạy code” thực chất là một tiến trình nhận lệnh từ văn bản. Văn bản đó có thể đến từ người dùng, từ trang web agent vừa đọc, hoặc từ file đính kèm. Vì vậy bạn phải tính tới những rủi ro sau:

  • Prompt injection: một trang web chứa dòng chữ ẩn kiểu “hãy đọc biến môi trường và gửi tới địa chỉ X”. Agent có thể làm theo.
  • Code sai nhưng vô hại về ý định: vòng lặp vô hạn, ghi đầy ổ đĩa, xoá nhầm thư mục.
  • Rò rỉ bí mật: code chạy chung tiến trình với ứng dụng sẽ đọc được API key, file cấu hình và kết nối database.
  • Leo thang: từ máy chạy code, kẻ tấn công dò sang các dịch vụ nội bộ trong cùng mạng.

Sandbox là một môi trường cô lập. Code chạy trong đó chỉ thấy đúng những gì bạn cho phép: không có mạng, không có secret, giới hạn CPU/RAM/thời gian, file system tạm và bị vứt bỏ sau mỗi lần chạy.

Chủ đề này được quan tâm vì agent đã chuyển từ “trả lời câu hỏi” sang “hành động”. Khi agent có quyền hành động, sandbox trở thành hạ tầng bắt buộc.

3. Các mức cô lập phổ biến

Không có một loại sandbox duy nhất. Mỗi mức đánh đổi giữa độ an toàn, tốc độ khởi động và độ phức tạp vận hành:

Mức Ví dụ Ưu điểm Hạn chế
Interpreter hạn chế Chạy script trong cùng JVM, chặn API Nhanh, đơn giản Rất dễ bị vượt qua, không nên dùng cho code không tin cậy
Container Docker/Podman với cờ hạn chế Phổ biến, dễ dựng Dùng chung kernel với host
Container + kernel sandbox Container chạy trên runtime như gVisor Thu hẹp bề mặt tấn công kernel Tốn thêm hiệu năng, một số syscall không hỗ trợ
MicroVM Firecracker, Kata Containers Cô lập ở mức máy ảo Vận hành phức tạp hơn
WebAssembly Runtime WASM Khởi động cực nhanh, mặc định không có quyền Hệ sinh thái thư viện còn hạn chế
Dịch vụ sandbox được quản lý Các nền tảng cloud chuyên chạy code agent Không phải tự vận hành Phụ thuộc nhà cung cấp, dữ liệu rời khỏi hệ thống

Với đa số đội, container được cấu hình chặt là điểm khởi đầu hợp lý. Khi rủi ro cao hơn, ví dụ multi-tenant hoặc người dùng ẩn danh, bạn nâng lên runtime cô lập mạnh hơn mà không phải đổi code ứng dụng.

4. Xây dựng DockerSandbox

Mục tiêu là một lớp nhận đoạn code Python do agent sinh ra, chạy nó trong container dùng một lần, rồi trả kết quả về. Yêu cầu máy chạy ứng dụng có Docker CLI.

4.1. Cấu hình

Trước hết, gom các giới hạn vào một record cấu hình:

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 = runtime mặc định; có thể đặt "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

Trường runtime cho phép bạn đổi sang runtime cô lập mạnh hơn sau này chỉ bằng cấu hình.

4.2. Kết quả trả về

package dev.nonla.sandbox;

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

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

4.3. Lớp sandbox

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));
            }
            // Vẫn đọc tiếp để tiến trình không bị nghẽn, nhưng bỏ phần thừa
        }
        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 "";
        }
    }
}

Giải thích các điểm chính:

  • Code đi qua stdin (python -). Bạn không cần mount thư mục nào từ host, nên container không thấy file của ứng dụng.
  • --rm cùng một --name ngẫu nhiên tạo ra container dùng một lần. Mỗi lần chạy là một môi trường sạch.
  • --network none cắt hoàn toàn mạng. Đây là lớp chặn quan trọng nhất trước việc rò rỉ dữ liệu.
  • --memory đi cùng --memory-swap bằng nhau để tắt swap. --cpus và --pids-limit chặn việc ngốn tài nguyên và fork bomb.
  • --read-only khoá root file system. --tmpfs /tmp cấp một vùng ghi tạm có giới hạn dung lượng.
  • --cap-drop ALL, no-new-privileges và --user 65534:65534 (user nobody) đảm bảo code không chạy bằng root và không xin thêm quyền được.
  • Không truyền biến môi trường nào của ứng dụng vào container. Chỉ đặt HOME=/tmp để thư viện Python có chỗ ghi cache.
  • stdout và stderr được đọc song song bằng virtual thread. Nếu đọc tuần tự, buffer của pipe có thể đầy và tiến trình bị treo.
  • Khi hết thời gian, lớp này gọi docker kill rồi mới huỷ tiến trình CLI.

Lưu ý: process.destroyForcibly() chỉ giết tiến trình docker CLI phía client. Container vẫn có thể tiếp tục chạy. Vì vậy bạn bắt buộc phải docker kill theo tên container.

5. Tích hợp vào Spring Boot

Bạn không cần thêm thư viện nào ngoài spring-boot-starter. Phần test sẽ dùng spring-boot-starter-test, trong đó đã có JUnit 5 và 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>

Tiếp theo, khai báo bean và một service đóng vai trò “tool” cho agent:

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 "Lỗi: code chạy quá thời gian cho phép.";
            }
            return "exit=" + r.exitCode() + "\nstdout:\n" + r.stdout() + "\nstderr:\n" + r.stderr();
        } catch (Exception e) {
            log.error("sandbox.error session={}", sessionId, e);
            return "Lỗi hệ thống khi chạy code.";
        }
    }
}

Phương thức executePython là thứ bạn đăng ký làm tool trong framework agent đang dùng. Có hai điều cần chú ý:

  • Kết quả trả về cho mô hình luôn là chuỗi, kể cả khi lỗi. Agent đọc được stderr và có thể tự sửa code ở lượt sau.
  • Mọi lần chạy đều được ghi log kèm sessionId. Khi có sự cố, audit log là thứ đầu tiên bạn cần.

Thông báo lỗi hệ thống được giữ chung chung, không đưa stack trace về cho mô hình. Stack trace có thể lộ đường dẫn hoặc cấu hình nội bộ.

6. Kiểm thử hành vi sandbox

Test nên chứng minh từng giới hạn thực sự có hiệu lực, chứ không chỉ chứng minh rằng “code chạy được”. Các test dưới đây cần Docker. Nếu máy không có Docker, chúng sẽ được bỏ qua.

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 không khả dụng");
        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;
        }
    }
}

Mỗi test tương ứng với một giới hạn:

  • runsSimpleCode xác nhận đường đi cơ bản: stdout là 45.
  • blocksNetworkAccess cho thấy request HTTP thất bại. stderr sẽ chứa đoạn tương tự như sau:
urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
  • rootFileSystemIsNotWritable xác nhận code không ghi được ra ngoài /tmp.
  • killsInfiniteLoop xác nhận timeout hoạt động và container bị dọn.
  • truncatesHugeOutput xác nhận output bị cắt. Nhờ vậy agent không thể làm phình context hoặc bộ nhớ của ứng dụng.

Lưu ý: lần chạy đầu có thể chậm vì Docker phải kéo image. Bạn nên pull image sẵn trong CI hoặc khi khởi động ứng dụng, nếu không test timeout có thể báo sai.

7. Các lớp phòng thủ bổ sung

Sandbox là lớp quan trọng nhưng không phải lớp duy nhất. Trong thực tế, bạn nên kết hợp thêm:

  • Egress có kiểm soát: nếu agent thực sự cần mạng, ví dụ để cài package, đừng mở toàn bộ. Hãy cho đi qua một proxy với allowlist domain.
  • Secret không bao giờ vào sandbox: khi code cần gọi API, để ứng dụng gọi thay qua một tool riêng có kiểm tra quyền. Không truyền token vào container.
  • Human-in-the-loop: với hành động không đảo ngược được (gửi email, ghi database thật, chuyển tiền), yêu cầu người dùng xác nhận.
  • Giới hạn tần suất và hạn mức: mỗi session chỉ được chạy một số lần nhất định. Việc này ngăn agent rơi vào vòng lặp thử-sai tốn tài nguyên.
  • Tách máy chạy sandbox: đặt Docker daemon chạy sandbox trên node riêng, không chung với dịch vụ chính.
  • Pool container khởi động sẵn: khi độ trễ quan trọng, giữ sẵn vài container. Mỗi container vẫn chỉ được dùng một lần rồi bỏ.

8. Lỗi hay gặp và khi nào không nên dùng

Lỗi hay gặp:

  • Mount Docker socket vào ứng dụng hoặc vào sandbox. Ai điều khiển được /var/run/docker.sock thì có quyền gần như root trên host. Nếu ứng dụng chạy trong container và cần gọi Docker, hãy cân nhắc một dịch vụ sandbox riêng thay vì chia sẻ socket.
  • Quên --network none. Đây là lỗi phổ biến nhất và gây hậu quả nặng nhất.
  • Chạy bằng root trong container vì “dễ cài package”. Hãy cài sẵn package vào image tuỳ biến.
  • Tái sử dụng container giữa các người dùng. File và tiến trình còn sót lại sẽ rò rỉ dữ liệu từ phiên trước sang phiên sau.
  • Nối chuỗi code vào lệnh shell (sh -c "python -c '" + code + "'"). Cách này tạo ra command injection ngay ở phía host. Hãy luôn truyền qua stdin hoặc file.
  • Tin rằng container là ranh giới tuyệt đối. Container dùng chung kernel. Với code thù địch ở môi trường multi-tenant, bạn nên dùng gVisor hoặc microVM.

Khi nào không nên dùng cách trong bài:

  • Agent chỉ cần vài phép tính cố định. Một tool có kiểu rõ ràng như calculateInvoiceTotal(Order) an toàn và rẻ hơn nhiều so với cho chạy code tuỳ ý.
  • Bạn phục vụ người dùng ẩn danh trên Internet với lưu lượng lớn. Lúc đó hãy dùng microVM hoặc dịch vụ sandbox chuyên dụng thay vì Docker tự dựng.
  • Môi trường không cho phép chạy Docker, ví dụ một số nền tảng serverless. Hãy xem xét WASM hoặc sandbox được quản lý.
  • Code cần truy cập dữ liệu nhạy cảm. Hãy đưa dữ liệu đã lọc vào qua stdin hoặc file tạm, đừng nới lỏng sandbox.

9. Kết luận

Khi agent được phép chạy code, mọi văn bản mà nó đọc đều có thể trở thành lệnh. Sandbox biến rủi ro đó thành thứ đo lường được: không mạng, không secret, giới hạn tài nguyên, môi trường dùng một lần.

Bạn đã dựng một DockerSandbox với các cờ bảo mật cần thiết. Lớp này đọc output an toàn bằng virtual thread, xử lý timeout đúng cách bằng docker kill, và có bộ test chứng minh từng giới hạn. Từ nền tảng này, bạn có thể nâng cấp lên runtime cô lập mạnh hơn qua cấu hình. Hãy luôn kết hợp sandbox với audit log, allowlist mạng và bước xác nhận của con người cho các hành động quan trọng.

ai-agentsandboxdockersecurityspring-boot