Begitu kamu izinkan agent menjalankan kode yang baru dia tulis, kesalahannya berhenti jadi teks dan mulai menyentuh mesin kamu. Agent yang cuma bisa bikin diff itu sopan; agent yang bisa menjalankan test suite, memasang dependency, atau mengulang script yang gagal sampai lolos baru benar-benar berguna. Pembeda antara dua setup itu adalah sandbox, dan sandbox harus tetap menahan saat model menulis sesuatu yang bodoh atau bermusuhan.
docker run biasa memberi kamu container, bukan sandbox. Secara default prosesnya jalan sebagai root di dalam container, container dapat interface jaringan, dan dia menyimpan set capability Linux bawaan. Panduan security Docker sendiri gamblang soal ini: hanya user yang dipercaya yang boleh mengendalikan Docker daemon, karena sebuah container bisa diberi akses ke filesystem host. Output model bukan user yang dipercaya.
Yang berikut ini sandbox yang saya pakai: Dockerfile 6 baris, satu perintah docker run, dan empat tes untuk membuktikan tiap dinding benar-benar bekerja. Semua perintah dan semua baris output di bawah dijalankan di mesin ini (Docker 29.7.2, Linux, python:3.12-slim).
Prasyarat
- Docker Engine 24 atau lebih baru. Cek dengan
docker version. - Host Linux. Limit resource-nya memakai cgroup v2, yang sudah standar di distro sekarang.
- Satu direktori untuk volume kerja bersama. Direktori itu dimiliki user sandbox, bukan kamu.
- Terbiasa dengan Dockerfile dan membaca exit code.
Container belum otomatis jadi sandbox
Docker mengisolasi proses dengan namespace dan membatasinya dengan cgroup, dan keduanya fitur kernel yang sudah matang. Namespace mencegah container melihat proses host; cgroup mencegahnya menghabiskan seluruh memori kamu. Tapi keduanya tidak melindungi kernel host itu sendiri: container berbagi kernel host, jadi eksploit kernel adalah jalur keluar.
Halaman security Docker menarik garisnya dengan jelas. Set capability bawaan itu allowlist, bukan denylist; menjalankan proses sebagai user non-privileged menambah satu lapis; dan profil AppArmor atau SELinux menumpuk di atasnya. Kombinasi itu cukup untuk kode yang kamu tulis sendiri. Untuk kode yang tidak kamu tulis dan tidak bisa kamu prediksi, kamu butuh runtime yang lebih kuat, dan ada bagian soal itu di bawah.
Image-nya
# Dockerfile
FROM python:3.12-slim
RUN useradd --create-home --uid 10001 sandbox
WORKDIR /work
USER sandbox
ENTRYPOINT ["python3", "-I", "-B"]
Dua detail penting. Image diakhiri USER sandbox, jadi container tidak pernah start sebagai root. Dan entrypoint-nya menjalankan Python dalam isolated mode:
$ python3 -I -c "import sys; print(sys.flags.isolated, sys.flags.ignore_environment, sys.flags.no_user_site)"
1 1 1
Isolated mode mengabaikan environment variable PYTHON* dan direktori site per user, jadi script hasil generate tidak bisa menyuntikkan module lewat environment, dan -B mencegahnya menumpuk __pycache__ di volume kerja kamu.
Build image-nya:
docker build -t agent-sandbox:py312 .
Pasang semua yang boleh di-import agent di tahap build. Ini penting nanti, karena sandbox tidak punya jaringan.
Perintah run, flag per flag
docker run --rm \
--network none \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--memory 256m --memory-swap 256m \
--cpus 1 \
--pids-limit 32 \
--cap-drop ALL \
--security-opt no-new-privileges \
--user 10001:10001 \
--ulimit nofile=64 \
--workdir /work \
-v /srv/agent-sandbox/work:/work:rw \
agent-sandbox:py312 /work/task.py
Fungsi tiap flag:
--network nonemenghapus interface jaringan. Tidak ada eksfiltrasi, tidak ada instalasi paket saat runtime, tidak ada panggilan balik ke controller.--read-onlybikin filesystem container tidak bisa diubah, jadi satu-satunya tempat yang bisa ditulis adalah/tmpdan volume kerja yang di-mount.--tmpfs /tmp:rw,noexec,nosuid,size=64mmemberi ruang scratch yang tidak bisa menyimpan binary yang bisa dijalankan dan tidak bisa membesar.--memory 256m --memory-swap 256mmembatasi memori dan melarang swap, jadi alokasi yang kabur akan mati alih-alih menyeret host.--cpus 1dan--pids-limit 32membatasi CPU dan jumlah proses. Tanpa pids limit, fork bomb bisa menjatuhkan host.--cap-drop ALLmenghapus semua capability Linux, termasuk kemampuan mount, ganti uid, atau load module.--security-opt no-new-privilegesmemblokir binary setuid dari mendapat tambahan hak.--user 10001:10001menyamakan dengan user di dalam image, jadi kepemilikan file di host bisa diprediksi.--ulimit nofile=64mencegah banjir file descriptor.- Mount
-vhanya membuka satu direktori host. Jangan mount yang lain, dan jangan pernah mount Docker socket.
Empat tes untuk membuktikan dindingnya bekerja
Tulis script kecil ini di volume kerja lalu jalankan dengan flag di atas. Kalau ada dinding yang bocor, outputnya bilang begitu dengan bahasa yang jelas.
Isolasi jaringan:
$ docker run ... /work/net.py
NETWORK: blocked ([Errno 101] Network is unreachable)
Root filesystem read-only, volume kerja bisa ditulis:
$ docker run ... /work/rofs.py
/home/sandbox/out.txt: [Errno 30] Read-only file system: '/home/sandbox/out.txt'
/work/out2.txt: written
uid 10001 cwd /work
Home directory milik agent sendiri read-only, volume kerja tidak. Itu bentuk yang kamu mau: agent bisa menulis hasil, tidak bisa menulis yang lain.
Batas memori, mengalokasikan 900 MB di container 256 MB:
$ docker run ... /work/mem.py
$ echo $?
137
Tidak ada pesan error, tidak ada traceback. OOM killer dari kernel mengambil prosesnya, dan Docker melaporkan 137. Wrapper kamu harus menerjemahkan ini jadi sesuatu yang bisa dibaca model, kalau tidak agent akan mengulang alokasi yang sama terus.
Batas proses, fork di dalam loop:
$ docker run ... /work/forks.py
PIDS: stopped after 31 forks ([Errno 11] Resource temporarily unavailable)
Batas hak akses, mencoba jadi root:
$ docker run ... /work/priv.py
PRIV: cannot change uid ([Errno 1] Operation not permitted)
PRIV: running as uid 10001 gid 10001
Ada satu dinding lagi yang tidak bisa dites dari dalam container: batas waktu. Jalankan container secara detached, tunggu dengan deadline, lalu kill saat deadline lewat.
cid=$(docker run -d --rm --name sbx ... -c "import time; time.sleep(600)")
timeout 5 docker wait sbx; echo "wait exit=$?"
docker kill sbx
wait exit=124 (124 = masih jalan saat deadline lewat)
137
Angka 137 itu keluar dari docker wait setelah kill, dan itu kode yang sama dengan hasil OOM killer. Bedakan keduanya dengan mencatat kejadian mana yang aktif, bukan dari kodenya saja.
Wrapper yang dipanggil agent kamu
Tool yang dipanggil agent sebaiknya menyembunyikan semua ini di balik satu fungsi yang mengembalikan stdout, stderr, exit code, dan status apakah deadline terpicu.
#!/usr/bin/env python3
"""run_sandbox.py: jalankan satu potong kode hasil model di container terkunci."""
import dataclasses, pathlib, subprocess, time, uuid
IMAGE = "agent-sandbox:py312"
WORKDIR = pathlib.Path("/srv/agent-sandbox/work") # dimiliki uid 10001
@dataclasses.dataclass
class Result:
exit_code: int
stdout: str
stderr: str
seconds: float
timed_out: bool
def run(code: str, *, timeout: int = 30) -> Result:
name = f"sbx-{uuid.uuid4().hex[:8]}"
task = WORKDIR / f"{name}.py"
task.write_text(code)
task.chmod(0o644)
cmd = [
"docker", "run", "--name", name, "--rm",
"--network", "none",
"--read-only",
"--tmpfs", "/tmp:rw,noexec,nosuid,size=64m",
"--memory", "256m", "--memory-swap", "256m",
"--cpus", "1", "--pids-limit", "32",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--user", "10001:10001",
"--ulimit", "nofile=64",
"--workdir", "/work",
"-v", f"{WORKDIR}:/work:rw",
IMAGE, f"/work/{task.name}",
]
started = time.monotonic()
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
timed_out = False
try:
out, err = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
timed_out = True
subprocess.run(["docker", "kill", name], capture_output=True)
out, err = proc.communicate()
finally:
task.unlink(missing_ok=True)
return Result(proc.returncode, out.strip(), err.strip(),
round(time.monotonic() - started, 1), timed_out)
Menjalankannya dengan tiga input, di mesin ini:
exit=0 0.4s out='3.12.14'
exit=137 0.9s stdout='' timed_out=False
exit=137 5.1s timed_out=True
Baris kedua adalah memory bomb: dibunuh kernel setelah 0,9 detik, dan wrapper tahu itu bukan timeout. Baris ketiga adalah sleep yang kena deadline 5 detik, dan wrapper tahu itu timeout. Kirim kedua fakta itu ke model saat melaporkan kegagalan, karena "proses kamu dibunuh karena memakai memori terlalu banyak" dan "proses kamu jalan terlalu lama" mengarah ke perbaikan yang berbeda.
Catatan operasional
Volume kerja harus bisa ditulis oleh uid 10001. Saya kehilangan sepuluh menit karena ini: direktori kerja yang dimiliki uid lain memberi Permission denied saat menulis output, dan pesan itu terlihat seperti salah konfigurasi sandbox padahal cuma urusan permission Unix. Jalankan chown -R 10001:10001 /srv/agent-sandbox/work, dan biarkan proses wrapper memiliki direktori induknya supaya dia bisa menaruh file task di situ.
Bake dependency ke dalam image. Dengan --network none tidak ada pip install saat runtime, dan itu justru fitur: kumpulan paket yang bisa di-import jadi sesuatu yang kamu kontrol dan review, bukan sesuatu yang dipilih agent.
Jangan lewatkan credential ke sandbox. Batas container melindungi host dan jaringan kamu. Dia tidak melindungi nilai yang kamu taruh di environment variable, dan kode hasil generate yang mencetak os.environ akan menemukannya.
Jalankan satu container per task dan biarkan --rm membersihkannya. Container yang dipakai ulang membiarkan satu task meninggalkan state untuk task berikutnya, dan itu bikin kegagalan yang terbatas jadi kegagalan yang membingungkan.
Laporkan exit code sebagai bagian utama dari hasil. 137 artinya dibunuh, 124 artinya deadline kamu terpicu, dan 0 dengan output kosong biasanya berarti kodenya lolos tapi tidak mencetak apa pun. Agent yang cuma melihat stdout akan salah membaca ketiganya.
Kapan naik ke runtime yang lebih kuat
Setup ini berasumsi kodenya lebih sering salah daripada jahat. Kalau kodenya sepenuhnya dikendalikan penyerang, atau threat model kamu mencakup eksploit kernel dan container escape, kamu butuh runtime yang tidak berbagi kernel host. gVisor biasanya langkah berikutnya: dia menyediakan OCI runtime bernama runsc yang bisa dipasang ke Docker dan Kubernetes, dan dia menangani system call di userspace alih-alih meneruskannya ke kernel host. Firecracker dan Kata Containers melangkah lebih jauh dengan virtual machine ringan. Ketiganya punya harga: kompatibilitas atau waktu start, dan itu sebabnya container biasa tetap layak jadi default.
Runtime WASM ada di ujung lain tradeoff ini: permukaan system call jauh lebih kecil, tanpa filesystem, dan bahasa yang bisa jalan tanpa diubah jadi lebih sedikit.
Checklist
- Build image yang entrypoint-nya user non-root, dan kunci interpreter ke isolated mode.
- Pakai
--network none,--read-only, dan tmpfs berukuran terbatas dengannoexec. - Batasi memori, CPU, pids, dan open file, lalu hapus semua capability.
- Mount satu direktori kerja, jangan Docker socket, jangan home directory.
- Jalankan empat tes itu terhadap image kamu sendiri sebelum diserahkan ke agent.
- Bungkus container dalam fungsi yang mengembalikan exit code, stdout, stderr, durasi, dan status deadline.
- Naik ke gVisor atau microVM kalau kodenya bermusuhan, bukan sekadar tidak andal.