Bài 11 về tool design nói về schema validation và idempotency. Bài 5 về first agent đã wrap shell tool vào agent loop. Lần này leo lên một bậc nguy hiểm hơn: agent không chỉ gọi shell, mà tự viết code Python rồi chạy code đó.

Đây là use case Code Interpreter, cũng là use case nhiều team đang build khi cần agent tính toán số liệu, vẽ chart, xử lý CSV, hay chạy thử logic trước khi commit vào codebase. Nghe hấp dẫn. Và đúng là hấp dẫn, cho đến khi code LLM viết ra xóa hết filesystem hoặc mở kết nối outbound đến địa chỉ ngoài ý muốn.

Bài này tập trung vào ba lớp sandbox theo độ an toàn tăng dần, trade-off của từng lớp, và ví dụ code cụ thể để bắt đầu.

Vì sao code execution khác mọi tool khác

Trong series này, hầu hết tool là deterministic và có scope giới hạn: đọc file từ path cụ thể, gọi API với endpoint đã biết, query DB với schema đã định nghĩa. Tool execution tệ nhất thường là: sai argument, LLM truyền path không tồn tại, API trả lỗi 404.

Code execution khác. LLM sinh ra arbitrary code, rồi code đó được chạy. Không có schema giới hạn. Code Python có thể làm bất cứ điều gì Python làm được: đọc ghi bất kỳ file, gọi bất kỳ syscall, tạo tiến trình con, mở socket đi ra ngoài internet.

Không phải vì LLM có ý định xấu. Phần lớn trường hợp, code xấu đến từ:

  1. Prompt injection trong dữ liệu: agent đang xử lý CSV từ user, trong CSV có instruction ẩn yêu cầu thực thi lệnh hệ thống. Bài 24 đi sâu về prompt injection. Ở đây chỉ cần biết: dữ liệu đầu vào là attack surface.
  2. Hallucination gây ra side effect: LLM cố dùng library không có, cố write vào path không đúng, cố tạo file temp nhưng không cleanup.
  3. Vòng lặp vô hạn: agent loop bên ngoài có max_iterations, nhưng code bên trong tool thì không có giới hạn tương tự.

Kết quả: cần một lớp cách ly giữa code LLM viết và host system.

Ba lớp sandbox

Lớp 1: subprocess với timeout (yếu, nhanh)

Cách đơn giản nhất là chạy code qua subprocess.run, set timeout, capture stdout/stderr.

import subprocess
import sys
import tempfile
import os

def run_code_subprocess(code: str, timeout: int = 10) -> dict:
    """
    Lớp 1: subprocess tối giản. Nhanh nhưng KHÔNG cách ly filesystem hay network.
    Dùng cho: dev local, trusted code, demo nhanh.
    KHÔNG dùng cho: code từ user input hoặc external data.
    """
    with tempfile.NamedTemporaryFile(
        suffix=".py", mode="w", delete=False
    ) as f:
        f.write(code)
        tmp_path = f.name

    try:
        result = subprocess.run(
            [sys.executable, tmp_path],
            capture_output=True,
            text=True,
            timeout=timeout,
        )
        return {
            "stdout": result.stdout,
            "stderr": result.stderr,
            "returncode": result.returncode,
        }
    except subprocess.TimeoutExpired:
        return {
            "stdout": "",
            "stderr": f"Timeout sau {timeout}s",
            "returncode": -1,
        }
    finally:
        os.unlink(tmp_path)


# Tool definition cho agent loop
CODE_TOOL = {
    "name": "run_python",
    "description": "Run Python code snippet. Returns stdout, stderr, returncode.",
    "input_schema": {
        "type": "object",
        "properties": {
            "code": {"type": "string", "description": "Python code to execute"},
        },
        "required": ["code"],
    },
}

Điểm yếu của lớp này không phải là timeout. Timeout bắt được vòng lặp vô hạn. Điểm yếu là sau khi timeout, process con có thể vẫn còn sống.

Pitfall: subprocess timeout không kill sạch

Đây là fail mode mà tôi đã gặp trong production. Khi TimeoutExpired được raise, subprocess.run gọi proc.kill() và raise exception. Nhưng nếu code đang chạy trong subprocess đã spawn thêm child process của chính nó (subprocess.Popen bên trong code LLM viết, hay multiprocessing.Process), những process con đó không nhận được signal kill. Chúng trở thành zombie, tiếp tục dùng CPU, tiếp tục ghi file, tiếp tục giữ file descriptor.

Sau vài giờ agent chạy, hệ thống có hàng chục subprocess zombie. Một số mở kết nối outbound đến các địa chỉ trong CSV mà agent đang phân tích. Không ai biết cho đến khi monitoring báo spike connection.

Fix đúng: dùng process group để kill cả cây:

import os
import signal
import subprocess
import sys
import tempfile

def run_code_subprocess_safe(code: str, timeout: int = 10) -> dict:
    """
    subprocess với process group kill. Vẫn không cách ly filesystem/network,
    nhưng ít nhất không để zombie process.
    """
    with tempfile.NamedTemporaryFile(
        suffix=".py", mode="w", delete=False
    ) as f:
        f.write(code)
        tmp_path = f.name

    proc = None
    try:
        proc = subprocess.Popen(
            [sys.executable, tmp_path],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            # Tạo process group mới để kill được cả cây
            start_new_session=True,
        )
        stdout, stderr = proc.communicate(timeout=timeout)
        return {
            "stdout": stdout,
            "stderr": stderr,
            "returncode": proc.returncode,
        }
    except subprocess.TimeoutExpired:
        if proc:
            # Kill cả process group, không chỉ process trực tiếp
            os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
            proc.wait()
        return {
            "stdout": "",
            "stderr": f"Timeout sau {timeout}s. Process group killed.",
            "returncode": -1,
        }
    finally:
        try:
            os.unlink(tmp_path)
        except FileNotFoundError:
            pass

Lớp 1 với fix này vẫn không an toàn về filesystem hay network. Nhưng ít nhất không để rác.

Lớp 2: Docker container (trung bình, đủ cho nhiều use case)

Docker tạo namespace riêng cho filesystem, network, và process tree. Code chạy trong container không thể đọc /etc/passwd của host, không thể thoát ra ngoài network namespace mà container không được grant.

import subprocess
import uuid

def run_code_docker(
    code: str,
    timeout: int = 15,
    memory_limit: str = "256m",
    image: str = "python:3.12-slim",
) -> dict:
    """
    Lớp 2: Docker sandbox. Cách ly filesystem và network.
    Yêu cầu: Docker daemon đang chạy trên host.
    """
    container_name = f"agent-sandbox-{uuid.uuid4().hex[:8]}"

    cmd = [
        "docker", "run",
        "--rm",                          # auto-remove sau khi xong
        "--name", container_name,
        "--memory", memory_limit,        # giới hạn RAM
        "--memory-swap", memory_limit,   # không dùng swap
        "--cpus", "0.5",                 # 0.5 CPU
        "--network", "none",             # tắt hoàn toàn network
        "--read-only",                   # filesystem read-only (ngoại trừ /tmp)
        "--tmpfs", "/tmp:size=64m",      # /tmp có write, max 64MB
        "--no-healthcheck",
        image,
        "python3", "-c", code,
    ]

    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
        )
        return {
            "stdout": result.stdout[:10000],  # cap output
            "stderr": result.stderr[:2000],
            "returncode": result.returncode,
        }
    except subprocess.TimeoutExpired:
        # Kill container nếu còn sống
        subprocess.run(
            ["docker", "kill", container_name],
            capture_output=True,
        )
        return {
            "stdout": "",
            "stderr": f"Timeout sau {timeout}s. Container killed.",
            "returncode": -1,
        }

Điểm mạnh của lớp 2:

  • --network none cắt đứt outbound connection. Code không thể curl ra ngoài.
  • --read-only + --tmpfs /tmp cho phép code write tạm nhưng không ra ngoài container.
  • --memory--cpus ngăn code hút hết resource của host.

Điểm yếu:

  • Cold start: khởi động container mất 1-3 giây với slim image. Nếu agent gọi tool này mỗi bước, latency cộng dồn nhanh.
  • Không persistent: mỗi lần gọi là một container mới. Nếu agent cần chạy nhiều đoạn code liên quan (khai báo function ở bước 1, dùng function đó ở bước 2), Docker vanilla không giữ state.
  • Không có library: python:3.12-slim không có pandas, numpy, matplotlib. Phải build custom image hoặc dùng python:3.12 (nặng hơn nhiều).

Lớp 3: Cloud sandbox (mạnh, có persistent state)

e2b, Modal, Daytona là các service chạy sandbox trong VM hoặc microVM (Firecracker), với API để tạo, dùng, và destroy sandbox theo session. Điểm khác biệt quan trọng so với Docker local: sandbox tồn tại giữa nhiều lần gọi tool.

Đây là thứ cần thiết cho use case Jupyter-style: agent viết code tính x = df.groupby(...), bước sau dùng lại x. Không persistent state thì pattern này không hoạt động.

from e2b_code_interpreter import Sandbox

def create_agent_with_e2b_sandbox():
    """
    Tạo một session agent với e2b sandbox tồn tại suốt session.
    Code từ nhiều tool call chia sẻ cùng kernel Python.
    """
    sbx = Sandbox(timeout=300)  # sandbox sống tối đa 5 phút

    def run_code_e2b(code: str) -> dict:
        """Tool function: chạy code trong sandbox đang sống."""
        execution = sbx.run_code(code)
        results = []
        for result in execution.results:
            results.append(str(result))

        return {
            "stdout": execution.logs.stdout,
            "stderr": execution.logs.stderr,
            "results": results,
            "error": execution.error.traceback if execution.error else None,
        }

    return sbx, run_code_e2b


# Dùng trong agent loop
def run_data_analysis_agent(user_task: str, csv_content: str):
    import anthropic

    client = anthropic.Anthropic()
    sbx, run_code = create_agent_with_e2b_sandbox()

    tools = [
        {
            "name": "run_python",
            "description": (
                "Run Python in a persistent Jupyter-like kernel. "
                "Variables and imports persist across calls. "
                "pandas, numpy, matplotlib are pre-installed."
            ),
            "input_schema": {
                "type": "object",
                "properties": {
                    "code": {
                        "type": "string",
                        "description": "Python code to execute",
                    }
                },
                "required": ["code"],
            },
        }
    ]

    messages = [
        {
            "role": "user",
            "content": (
                f"{user_task}\n\n"
                f"CSV data:\n```\n{csv_content}\n```"
            ),
        }
    ]

    try:
        for _ in range(15):
            resp = client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=4096,
                tools=tools,
                messages=messages,
            )
            messages.append({"role": "assistant", "content": resp.content})

            if resp.stop_reason == "end_turn":
                for block in resp.content:
                    if hasattr(block, "text"):
                        return block.text
                return "Done"

            if resp.stop_reason == "tool_use":
                tool_results = []
                for block in resp.content:
                    if block.type == "tool_use" and block.name == "run_python":
                        result = run_code(block.input["code"])
                        tool_results.append(
                            {
                                "type": "tool_result",
                                "tool_use_id": block.id,
                                "content": str(result),
                            }
                        )
                messages.append({"role": "user", "content": tool_results})

        return "Max iterations reached"
    finally:
        sbx.close()

Điểm mạnh của e2b:

  • Sandbox chạy trên Firecracker microVM. Cách ly ở hypervisor level, không phải container namespace.
  • Kernel Python persistent: import pandas as pd ở lần gọi đầu, lần sau dùng pd trực tiếp được.
  • Library thường gặp (pandas, numpy, matplotlib, seaborn) đã có sẵn trong base image.
  • Network có thể cho phép có kiểm soát (e.g. chỉ whitelist pypi), hoặc tắt hoàn toàn.
  • SDK xử lý output parsing: kết quả matplotlib ra results, stdout/stderr ra logs.

Điểm yếu:

  • Chi phí theo thời gian sandbox sống. Không đóng sandbox sau khi xong là bỏ tiền.
  • Latency round-trip qua network API, không phải local process.
  • Cần quản lý lifecycle: tạo sandbox khi session bắt đầu, close khi xong. Nếu agent bị interrupt giữa chừng, sandbox leak nếu không có finally block.

So sánh ba lớp

Tiêu chísubprocessDocker locale2b / Modal
Filesystem isolationKhôngCó (—read-only)Có (microVM)
Network isolationKhôngCó (—network none)Có (configurable)
Resource limitsKhông (chỉ timeout)Có (—memory, —cpus)Có (per-tier)
Persistent stateKhôngKhôngCó (session-scoped)
Cold start~0ms1-3s3-6s (warm)
Chi phíChỉ CPU hostChỉ CPU hostTính theo giờ/request
Yêu cầu hạ tầngKhôngDocker daemonAPI key, internet
Phù hợp choDev local, trusted codeSingle-call sandboxMulti-step data analysis
Kill process treeCần xử lý thủ côngDocker tự xử lýAPI xử lý

Resource limits cần đặt

Dù chọn lớp nào, có bốn giới hạn cần đặt rõ ràng:

CPU. Code vô hạn thường là CPU hog trước khi là memory hog. Docker: --cpus 0.5. Subprocess: timeout gián tiếp kill CPU-bound code. e2b: tier-level.

Memory. LLM đôi khi sinh code tạo list khổng lồ hay đọc file vào RAM không check size. Docker: --memory 256m --memory-swap 256m. Không set swap riêng, container được dùng 2x limit.

Time. Timeout là guard cuối cùng. Đặt ở mức tối thiểu task cần. Data analysis trên CSV nhỏ: 15s đủ. Nếu cần lâu hơn, đó là dấu hiệu task không phù hợp với synchronous tool call.

Output size. Code có thể in ra hàng triệu dòng. Không cap output thì context window của agent bị flooded. Cap ở 10.000 ký tự stdout là điểm bắt đầu hợp lý. Nếu output lớn hơn, thông báo cho LLM và đề nghị nó rút gọn.

Filesystem isolation và persistent state

Hai yêu cầu này mâu thuẫn với nhau theo cách thú vị:

  • Isolation đòi hỏi mỗi lần chạy phải trong sandbox mới, sạch, không trạng thái.
  • Persistent state đòi hỏi sandbox sống qua nhiều lần chạy, giữ variable và file.

Cách giải quyết phổ biến:

  1. Một sandbox per agent session. Sandbox tạo khi agent loop bắt đầu, destroy khi end_turn. Mọi tool call trong session chia sẻ sandbox đó. Đây là pattern e2b SDK dùng.

  2. Mounted volume có kiểm soát. Nếu dùng Docker, mount một directory tạm dành riêng cho session vào /workspace. Code đọc/ghi trong /workspace nhưng không ra ngoài. Directory xóa sau khi session xong.

  3. Serialize state qua file. Agent ghi kết quả trung gian ra /tmp/state.json, lần sau đọc lại. Đơn giản, portable, không cần shared kernel. Chậm hơn in-memory state.

Khi nào không cần sandbox

Nếu agent chỉ chạy code trong bộ tool đã định nghĩa sẵn (đọc file cụ thể, query DB với schema cố định, gọi API đã whitelist), không cần code execution sandbox. Sandbox chỉ cần khi agent nhận code tùy ý từ LLM hoặc từ user rồi thực thi.

Ví dụ không cần: agent CRM gọi tool get_customer(id), update_customer(id, fields). Tool là Python function đã viết sẵn, không phải code LLM sinh ra.

Ví dụ cần: agent data analyst nhận request “phân tích CSV này, tính correlation matrix, vẽ heatmap”, rồi LLM tự viết code pandas để làm.

Ranh giới: code do ai viết. Nếu là dev viết (trong tool implementation), không cần sandbox. Nếu là LLM viết lúc runtime, cần sandbox.

Cheatsheet

Tình huốngLớp nên dùng
Dev local, test nhanh, code trustedsubprocess + process group kill
Single-call sandbox, không cần libraryDocker —network none —read-only
Multi-step data analysis, pandas/matplotlibe2b hoặc Modal
Agent trong production, user upload filee2b tối thiểu, microVM isolation
CI/CD pipeline, không có Docker daemone2b hoặc Modal

Lời kết

Code execution là tool mạnh nhất mà agent có thể được trao. Mạnh không phải vì LLM giỏi viết code, mà vì code có thể tác động lên bất kỳ thứ gì hệ thống cho phép. Điều đó có nghĩa là sandbox không phải tối ưu hóa. Đó là điều kiện tiên quyết.

Subprocess với timeout là điểm bắt đầu, nhưng nhớ fix process group kill. Docker là đủ cho đa số use case production đơn giản. e2b và Modal là lựa chọn khi cần persistent kernel qua nhiều bước, đặc biệt trong data analysis agent.

Bài tiếp theo là Browser automation cho agent: Playwright và computer use. Nếu code execution là cho agent “tính toán”, browser automation là cho agent “nhìn và tương tác”. Thêm một attack surface nữa cần nghĩ đến, nhưng cũng thêm một lớp capability mà automation truyền thống không làm được.