sandbox — il guscio kernel-level attorno agli executor
La sandbox è lo strato 3 dell'architettura di Metnos (cap. 6 dell'Architettura): wrappa l'esecuzione di un executor in bubblewrap per isolarla dal resto del sistema. Il modulo è piccolo (~180 righe) perché tutto quel che serve sta nel mappare il manifest dell'executor sui flag di bwrap; non c'è un demone né uno stato persistente.
bwrap; l'executor viene lanciato confinato, con rete isolata quando nessuna capability la richiede.
La pseudo-sandbox del runtime — filtro path e host nei wrapper degli executor, in cooperazione col Vaglio — resta come prima linea di difesa: fa controlli prima ancora di lanciare il subprocess. bwrap aggiunge sopra di essa un guscio kernel-level: anche se un executor riuscisse a evadere i controlli applicativi, troverebbe namespace separati, filesystem read-only e niente rete.
sandbox.wrap_command(executor, cmd) e riceve indietro un comando wrappato pronto per subprocess.run. Se bwrap manca, il comando passa invariato.
Il modulo runtime/sandbox.py espone quattro funzioni pubbliche. Niente classi: lo stato globale è nullo a parte la cache di shutil.which.
| Funzione | Cosa fa | Citazione |
|---|---|---|
bwrap_available |
True se bwrap è nel PATH. Risultato cached al primo accesso da shutil.which. |
runtime/sandbox.py:30-32 |
sandbox_disabled |
True se l'utente ha disabilitato esplicitamente la sandbox via METNOS_SANDBOX (valori riconosciuti: 0|off|no|false, case-insensitive). |
runtime/sandbox.py:35-40 |
wrap_command(executor, cmd, autonomy="supervised", extra_ro=None, extra_rw=None) |
Funzione principale. Ritorna il comando wrappato in bwrap se disponibile e non disabilitato; altrimenti il comando invariato. executor deve esporre code_path (Path) e capabilities (lista, formato manifest). |
runtime/sandbox.py:178-207 |
status |
Dict con bwrap_available, bwrap_path, disabled_via_env, active. Per dashboard e debug. |
runtime/sandbox.py:212-219 |
Internamente, wrap_command delega a tre helper privati:
_expand_hints_to_paths(hints) — tronca i glob ai loro ancestor (runtime/sandbox.py:45-69);_capability_kind(cap) e _capability_mode(cap) — estraggono famiglia (fs, network, code, …) e modalità (read, write, http, …) dal nome della capability (runtime/sandbox.py:72-93);_build_bwrap_args(code_path, capabilities,...) — costruisce la lista completa di flag bwrap a partire dal manifest (runtime/sandbox.py:106-175).
Il cuore del modulo è la funzione _build_bwrap_args: prende il code_path dell'executor e la sua lista di capabilities, e produce la sequenza esatta di flag da passare a bwrap. Le regole, in ordine.
Bwrap parte da una root vuota: bisogna montare esplicitamente i path di sistema che servono al Python interpreter e alle librerie. Il modulo monta in read-only solo quelli che esistono davvero (altrimenti bwrap fallisce con errore):
_SYSTEM_RO_PATHS = ( "/usr", "/bin", "/sbin", "/lib", "/lib64", "/lib32", "/etc", "/opt", "/var/lib/python3", )
Per ognuno, se Path(p).exists, viene aggiunto --ro-bind p p. Su sistemi minimi (es. container Alpine senza /lib32) i path mancanti sono saltati senza errore (runtime/sandbox.py:100-124).
Tre montaggi obbligatori, sempre presenti:
--proc /proc — un /proc minimale generato da bwrap, niente accesso a /proc/<pid> di processi esterni;--dev /dev — un /dev minimale (null, zero, random, …), niente /dev/sda;--tmpfs /tmp — /tmp privato dell'executor, montato in RAM, nessuna intersezione col /tmp dell'host.
Citazione: runtime/sandbox.py:127-129.
Il file Python dell'executor deve essere leggibile. Si monta in read-only tutta la sua directory:
code_dir = code_path.parent args += ["--ro-bind", str(code_dir), str(code_dir)]
Cosí l'executor può importare moduli accessori che vivono nello stesso pacchetto, ma non può modificare il proprio codice (runtime/sandbox.py:131-133).
Per ogni capability del manifest, il modulo guarda kind (famiglia) e mode (modalità):
| Capability | Effetto |
|---|---|
fs:read con hint | per ogni hint si calcola l'ancestor (vedi 3.5) e si aggiunge --ro-bind <path> <path>. |
fs:write con hint | come sopra, ma --bind (read-write). |
network:* | nessun bind aggiuntivo, ma flag has_network = True (vedi 3.6). |
code:exec | nessun bind aggiuntivo: i tool consueti vengono già da /usr/bin di 3.1. |
altre famiglie (mail, time, …) | nessun effetto sulla sandbox. |
Solo i path che esistono vengono effettivamente montati: un hint che punta a una cartella non ancora creata viene saltato, niente errore (runtime/sandbox.py:135-155).
Gli hint nel manifest sono glob-like (es. ~/notes/**, /tmp/**): bwrap non li capisce, monta cartelle. _expand_hints_to_paths tronca ogni hint al primo segmento glob, espande la ~, deduplica:
"~/notes/**" → "/home/user/notes" "/tmp/**" → "/tmp" "/tmp/*" → "/tmp" "~/Pictures" → "/home/user/Pictures"
Risultato: bwrap monta l'intera radice, non i singoli file matching. La granularità fine resta al filtro applicativo del runtime (runtime/sandbox.py:45-69).
Se nessuna capability ha famiglia network, viene aggiunto --unshare-net: l'executor parte in un namespace di rete vuoto, nessuna interfaccia oltre il lo down. Se almeno una capability è network:*, il flag non viene aggiunto e l'executor eredita la rete dell'host (runtime/sandbox.py:166-167).
Indipendentemente dal manifest, ogni sandbox include:
--unshare-user --unshare-ipc --unshare-uts --die-with-parent
--unshare-user — user namespace separato, l'executor non vede gli UID dell'host;--unshare-ipc — nessun semaforo o coda IPC condiviso;--unshare-uts — hostname e domainname separati;--die-with-parent — se il runtime muore, l'executor muore con lui, niente processi orfani.
Citazione: runtime/sandbox.py:170-173.
La sandbox deve essere un beneficio, non un blocco. Tre livelli di fallback assicurano che il sistema continui a funzionare anche quando bwrap non c'è:
| Caso | Comportamento | Citazione |
|---|---|---|
(a) bwrap non installato (bwrap è opzionale; la sandbox degrada con grazia in sua assenza) |
bwrap_available ritorna False, wrap_command ritorna il comando invariato. |
runtime/sandbox.py:30-32, 195-196 |
(b) METNOS_SANDBOX=0|off|no|false |
sandbox_disabled ritorna True, wrap_command ritorna il comando invariato. Utile per debug locale o CI senza bwrap. |
runtime/sandbox.py:35-40, 195-196 |
(c) eccezioni di shutil.which |
L'eccezione si propaga come False di bwrap_available: comando invariato. Niente crash sul caso "PATH rotto". |
runtime/sandbox.py:32, 195 |
In tutti e tre i casi, la pseudo-sandbox del runtime (filtro path/host nei wrapper degli executor + Vaglio) resta attiva: la difesa applicativa non sparisce per l'assenza di quella kernel-level. Si perde il guscio esterno, non il filtro interno.
boot.py che sandbox.status["active"] sia True), ma il modulo in sé non pretende.
agent_runtime
Il pianificatore chiama la sandbox in un solo punto: la funzione invoke_executor. Vediamo il codice (runtime/agent_runtime.py:194-212):
def invoke_executor(executor, args, timeout_s=30, *, autonomy="supervised"): """Invoca un executor, opzionalmente in sandbox bubblewrap. Se `bwrap` e' installato e `METNOS_SANDBOX` non e' disabilitato, il comando viene wrappato; altrimenti gira come subprocess Python diretto (la pseudo-sandbox del runtime resta attiva: filtro path/host + Vaglio). """ import sandbox as _sandbox # lazy: evita import circolare e overhead per moduli che non lo usano payload = json.dumps(args) base_cmd = ["python3", str(executor.code_path)] cmd = _sandbox.wrap_command(executor, base_cmd, autonomy=autonomy) result = subprocess.run( cmd, input=payload, capture_output=True, text=True, timeout=timeout_s, )...
Tre dettagli del codice meritano attenzione.
Lazy import. Il modulo sandbox viene importato dentro la funzione, non in cima al file. Questo evita due problemi: cicli di import (il modulo sandbox non dipende da agent_runtime, ma il pattern lazy è difensivo) e overhead per i moduli che usano agent_runtime ma non chiamano mai invoke_executor (es. test che esercitano solo il loop ReAct in dry-run). La cache interna di Python rende il costo del lazy import trascurabile dopo la prima chiamata.
Comando base costante. base_cmd è sempre ["python3", <code_path>]. La sandbox lo prefissa con ["bwrap", *flags, "--",...]; senza sandbox resta base_cmd intatto. subprocess.run non distingue i due casi: lavora sulla lista finale.
Parametro autonomy pass-through. Oggi wrap_command riceve autonomy ma non lo usa per differenziare i flag (vedi cap. 6). Lo accetta come parametro riservato: quando arriveranno i profili separati, basta cambiare _build_bwrap_args senza toccare le call-site.
La chiamata dal loop ReAct è in runtime/agent_runtime.py:540 (obs = invoke_executor(executor, args)): nessun parametro autonomy esplicito, default "supervised".
L'Architettura cap. 12 definisce tre livelli di autonomia — ReadOnly, Supervised, Full — con politiche diverse di accesso al sistema. La sandbox espone il parametro autonomy ma non applica profili separati: oggi tutti i wrap derivano lo stesso schema dal manifest, indipendentemente dal livello.
È una scelta dichiarata. Il manifest porta già le capability necessarie e i loro hint; introdurre un secondo asse "profilo per livello" qui produrrebbe duplicazione (ogni capability andrebbe filtrata due volte) e rimanderebbe la decisione politica nel modulo sbagliato. Il posto giusto per una tabella autonomy×capability è policy.html: il runtime, in base al livello scelto da Roberto, passerà a wrap_command il profilo che la policy avrà computato. Allora autonomy diventerà un selettore vero, non un parametro pass-through.
Quando l'integrazione di policy sarà completa, _build_bwrap_args riceverà un argomento profile derivato e applicherà restrizioni differenziate (es. ReadOnly forza --ro-bind anche per capability che dichiarano fs:write; Full disabilita --unshare-net a prescindere dalle capability dichiarate).
Cluster sandbox nel framework di test del runtime: 9/9 verde alla data. I casi sono pensati per esercitare ogni regola di derivazione e ogni livello di fallback senza richiedere bwrap installato.
| # | Caso | Cosa verifica |
|---|---|---|
| 1 | status_torna_dict | status ritorna un dict con le chiavi attese (bwrap_available, bwrap_path, disabled_via_env, active). |
| 2 | wrap_command_no_bwrap_passa_invariato | Quando bwrap_available è False, wrap_command ritorna esattamente il comando di input (lista uguale). |
| 3 | sandbox_disabled_rispetta_env | METNOS_SANDBOX=0 (e varianti) disabilita il wrapping anche con bwrap presente. |
| 4 | expand_hints_tronca_al_glob | Hint glob-like (es. /tmp/**) sono troncati al primo separatore glob; ~ espansa; duplicati eliminati. |
| 5 | capability_kind_e_mode_parse | _capability_kind e _capability_mode riconoscono nomi fs:read, network:http, code:exec; gestiscono sia dict sia stringa. |
| 6 | build_bwrap_args_isola_rete_se_no_network_cap | Manifest senza capability network:* → gli args contengono --unshare-net. |
| 7 | build_bwrap_args_lascia_rete_se_network_cap | Manifest con network:http → gli args non contengono --unshare-net. |
| 8 | build_bwrap_args_bind_rw_per_fs_write | Capability fs:write con hint produce --bind; fs:read produce --ro-bind. |
| 9 | build_bwrap_args_include_code_dir_ro | Gli args includono sempre --ro-bind <code_dir> <code_dir> derivato da executor.code_path.parent. |
I casi 6-9 esercitano _build_bwrap_args senza chiamare bwrap: si verifica la lista di flag prodotta. Cosí il cluster gira verde anche su un server di sviluppo dove bwrap non è installato, mentre coprendo le regole di derivazione che sono il contratto vero del modulo.
Per onestà: nel server di sviluppo, alla sera, bwrap non è installato. Il modulo sandbox gira in modalità fallback (caso (a) del cap. 4): tutti i comandi degli executor partono come subprocess.run(["python3", <code_path>],...), senza wrapping kernel-level.
L'attivazione richiede una sola operazione di sistema:
# Debian/Ubuntu sudo apt install bubblewrap # Fedora/RHEL sudo dnf install bubblewrap # Arch sudo pacman -S bubblewrap
Niente cambio di codice, niente riavvio del runtime. Al primo accesso successivo, bwrap_available ritorna True (cached), e da quel momento ogni invoke_executor wrappa automaticamente. status rifletterà active: True.
metnos-server (la macchina.33 del progetto) ha senso installare bwrap appena si esce dalla fase POC pura e si comincia a far girare executor sintetizzati su input non controllato. La pseudo-sandbox copre i casi normali, ma un executor sintetizzato da LLM su prompt "esotico" può sempre uscire dai binari attesi: il guscio kernel-level è la rete di sicurezza.
| Limite | Quando si toglie |
|---|---|
Niente landlock. Il filtro filesystem fine-grain via landlock richiede kernel ≥ 5.13 e syscall dedicate. Per ora ci affidiamo ai bind di bwrap. |
, quando si stabilizzerà la pipeline di approval con dispatcher callback maturo. Landlock può sostituire alcuni bind read-only con permessi più granulari (lettura sí, exec no, ecc.). |
| Niente Docker namespace. Per casi che richiedono isolamento ancora più severo (es. executor che girano LLM locali con dipendenze native pesanti), un container Docker o podman sarebbe più appropriato. | Quando un executor specifico renderà necessario l'isolamento completo (es. CUDA, librerie native di calcolo scientifico): si introdurrà un secondo backend selezionabile dal manifest (sandbox_backend = "docker"). |
Niente seccomp custom. Si usa il filtro syscall di default di bwrap (già restrittivo: blocca ptrace, kexec, …). Niente policy custom per famiglia di executor. |
Quando emergeranno minacce concrete che il default non copre. Oggi non vale la complessità di mantenere profili seccomp per ogni capability. |
Profili separati per autonomy. autonomy è pass-through e tutto deriva dal manifest in modo identico per ogni livello. |
L'integrazione della tabella autonomy×capability di policy permetterà al runtime di passare un profilo computato a wrap_command. |
Niente whitelist di rete. Una capability network:* oggi lascia la rete completamente aperta; non si filtra per host (es. solo *.example.com). |
Quando il pool di executor conterrà abbastanza chiamanti web da rendere il filtro per host un guadagno netto. Implementazione: nftables dentro il network namespace, oppure proxy LAN dedicato che fa enforcement. |
La sandbox è un componente piccolo (~180 righe) ma centrale nella postura di sicurezza di Metnos. La sua piccolezza è il punto: tutta la complessità sta nel manifest dell'executor, che è il contratto leggibile. Il modulo sandbox.py è pura traduzione meccanica.
Il fallback graceful, in particolare, riflette una scelta etica oltre che pragmatica: non si vuole che la sicurezza diventi un ostacolo all'ingresso. Su un laptop di sviluppo o in un container minimale, il sistema gira lo stesso, con la pseudo-sandbox applicativa attiva. Quando si passa al server di produzione, una apt install bubblewrap aggiunge il guscio kernel-level senza toccare il codice.