sandbox — il guscio a livello kernel attorno agli executor
La sandbox è lo strato 3 dell'architettura di Metnos (cap. 6 dell'Architettura): avvolge 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 a livello kernel: 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 avvolto 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 messo in cache 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 avvolto 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 cruscotti e diagnostica. |
runtime/sandbox.py:212-219 |
Internamente, wrap_command delega a tre helper privati:
_expand_hints_to_paths(hints) — tronca i glob alle loro cartelle radice (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 all'interprete Python 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 la cartella radice (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 in stile glob (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 corrispondenti. 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).
runtime/capabilities.py calcola prima le capability effettive usando
il valore finale degli argomenti e, se assente, il default dello schema. La forma
ammessa di when è chiusa: {arg, values}, con argomento
dichiarato e valori stringa compatibili con l'eventuale enum. Una clausola
malformata o non corrispondente è inattiva.
Per un executor conforme sandbox.invocation_skills estrae binding solo
da provider:access e li accetta soltanto se appartengono a
vocab.PROVIDER_SKILLS. Quel singolo risultato apre la rete, monta in
lettura-scrittura la sola home della skill (necessaria al refresh OAuth) e impedisce
il trasferimento dell'invocazione a un dispositivo. Gli executor legacy conservano
temporaneamente i cinque segnali storici come ripiego esplicito.
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 ripiego assicurano che il sistema continui a funzionare anche quando bwrap non c'è:
| Caso | Comportamento | Citazione |
|---|---|---|
(a) bwrap non installato (bwrap è opzionale; la sandbox degrada in modo controllato 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 diagnostica 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 a livello kernel. 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.
Import ritardato. 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 l'accorgimento è difensivo) e costo aggiuntivo 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 dell'import ritardato 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 trasmesso tale e quale. 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 i punti di chiamata.
La chiamata dal ciclo 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 autonomia×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 trasmesso tale e quale.
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 l'avvolgimento anche con bwrap presente. |
| 4 | expand_hints_tronca_al_glob | Hint in stile glob (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.
Se bwrap non è installato, il modulo sandbox usa il ripiego
descritto al cap. 4: il comando viene eseguito direttamente, senza involucro a
livello kernel, mentre restano attivi i controlli applicativi dentro gli
executor e Vaglio. Il risultato deve dichiarare questa condizione invece di
nasconderla.
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 (da cache), e da quel momento ogni invoke_executor avvolge automaticamente il comando. status rifletterà active: True.
bwrap prima di eseguire executor sintetizzati o codice non ancora
molto rodato. I controlli applicativi coprono i casi normali; il guscio a
livello kernel è la rete di sicurezza quando un executor esce dal percorso
atteso.
| Limite | Quando si toglie |
|---|---|
Niente landlock. Il filtro filesystem a grana fine via landlock richiede kernel ≥ 5.13 e syscall dedicate. Per ora ci affidiamo ai bind di bwrap. |
Quando si stabilizzerà il flusso di approvazione con un dispatcher dei 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 personalizzato. Si usa il filtro syscall di default di bwrap (già restrittivo: blocca ptrace, kexec, …). Niente policy su misura 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 autonomia. autonomy è trasmesso tale e quale e tutto deriva dal manifest in modo identico per ogni livello. |
L'integrazione della tabella autonomia×capability di policy permetterà al runtime di passare un profilo computato a wrap_command. |
Niente lista bianca di rete. Una capability network:* oggi lascia la rete completamente aperta; non si filtra per host (es. solo *.example.com). |
Quando l'insieme 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 applica le regole. |
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 ripiego controllato, 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 a livello kernel senza toccare il codice.
Tutti i capitoli precedenti parlano di un solo luogo: il server Metnos, dove bwrap avvolge gli executor sintetizzati in casa. Ma un executor può anche girare su un dispositivo remoto associato — il tuo portatile, un PC in un'altra stanza — guidato dal client scritto in Rust (client-rs/). Lì non c'è bwrap. Il contenimento lo sceglie il client stesso, e cambia a seconda del sistema operativo del dispositivo.
L'idea di fondo è semplice. Il server non sa (e non vuole sapere) come ogni dispositivo isola il codice: si fida di un contratto unico. Ogni sistema operativo ha il suo modulo — sandbox_linux.rs, sandbox_windows.rs, sandbox_macos.rs — ma tutti espongono la stessa identica funzione. Cambia il motore sotto il cofano; il volante è lo stesso.
sandbox_<os>.rs espone questa firma:
run_sandboxed(exec, python, shim, args, env, limits) -> OutputIl campo
limits.wall è la scadenza: il tempo massimo concesso all'esecuzione. Chi chiama non deve sapere quale isolamento è stato usato: riceve indietro un Output uniforme, che dichiara in chiaro quale contenimento è stato applicato. Riferimenti: ADR 0011 / 0037 / 0046.
Ogni sistema operativo offre strumenti di isolamento diversi. Il client usa il più forte disponibile su quella piattaforma. Ecco il quadro completo.
| Sistema | Contenimento | Robustezza | Come si compensa |
|---|---|---|---|
| Linux | bubblewrap + landlock + seccomp + spazio-dei-nomi di montaggio |
forte (pari al server Metnos) | — (nessuna compensazione necessaria) |
| macOS | sandbox-exec + entitlements |
media | non ancora realizzata (livello 2) |
| Windows | Job Object (in futuro anche AppContainer) | media; abbattimento dell'intero albero dei processi garantito | firma del server verificata prima di eseguire; solo executor di sola lettura e autosufficienti; quelli che modificano dati e non sono impacchettabili vengono rifiutati |
Su Linux il dispositivo remoto usa lo stesso identico guscio del server: bwrap + landlock + seccomp + spazio-dei-nomi di montaggio. Se su quel dispositivo bwrap non c'è, vale lo stesso ripiego onesto del cap. 4: si esegue in modo diretto, la cosa viene annotata nei registri (§2.8) e il risultato lo dichiara con sandbox:"none". Niente finzioni: chi legge il risultato sa che il guscio non c'era.
Windows non ha un equivalente di bwrap. Serve un altro strumento del sistema, chiamato Job Object. È un contenitore del cuore di Windows (il kernel NT) a cui si legano uno o più processi.
L'intuizione è questa: un Job Object è come una stanza chiusa a chiave. Ci metti dentro il processo dell'executor. Se quel processo ne genera altri (processi figli), anche loro nascono dentro la stessa stanza: nessuno può uscire. E quando chiudi la stanza a chiave, tutto ciò che è dentro viene spento con certezza — anche i figli, anche i nipoti. Nessun processo resta acceso di nascosto.
Il client Windows usa questa primitiva per costruire il contenimento intorno all'executor. Vediamo passo per passo come il client crea la stanza, vi inserisce il processo e la chiude quando scade il tempo massimo.
Due dettagli rendono robusto questo schema. Primo: il processo viene creato fermo (CREATE_SUSPENDED) e messo nella stanza prima di partire. Se partisse subito, per un istante potrebbe generare figli fuori dal contenitore; nascendo fermo, quando lo si avvia è già dentro. Secondo: al passo 2 si imposta il segnale JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Vuol dire: «quando l'ultimo riferimento a questa stanza si chiude, spegni tutto». Così l'abbattimento dell'intero albero dei processi è garantito anche se il client stesso va in errore e muore: chiudendosi, si porta dietro tutto. Il risultato lo dichiara con sandbox:"job-object".
limits.wall), il Job Object viene chiuso: l'albero è morto. Il risultato è ok:false, error_class:"timeout", con un contenuto vuoto — mai un risultato a metà spacciato per completo (§2.8). Verificato dal vivo: appena chiusa la stanza, il dispositivo è subito pronto e sano per l'esecuzione successiva.
Onestà: il Job Object è forte nello spegnere i processi, ma non isola disco e rete come fa bwrap su Linux. La difesa più solida su Windows è AppContainer con permessi espliciti su file e rete. Finche' quel livello non è attivo, Metnos compensa prima di eseguire, scegliendo con cura cosa mandare al dispositivo:
In pratica: al dispositivo si affida sia lettura sia scrittura, ma solo lavoro contenuto e annullabile; il contenimento e l'undo device-aware fanno il resto.
Un problema pratico: il dispositivo remoto potrebbe non avere affatto Python installato. Il client lo risolve da solo. Scarica al momento del bisogno un Python già pronto (python-build-standalone) e ne verifica l'integrità con l'impronta sha256 fissata dal server. Poi lo apre con tar + flate2, in puro Rust, senza dipendere da programmi esterni.
Lo scaricamento è costruito per resistere a reti instabili: procede a segmenti da 8 MB (richieste Range), riprende da dove si era interrotto grazie a un file .part, e se l'impronta non torna riscarica e confronta due copie fino a quando concordano. È la stessa disciplina già usata dal server in downloads.py::robust_fetch.
sandbox:"job-object".
Linux usa lo stesso modello di bwrap del server quando disponibile;
macOS (sandbox-exec + entitlements) resta un backend previsto ma non
ancora abilitato.