← Indice documentazione Microprogettazione › sandbox

Metnos

sandbox — il guscio a livello kernel attorno agli executor
Microprogettazione
Pubblico: chi vuole capire come Metnos isola gli executor dal resto del sistema.
Microprogettazione — allineata al codice al 1°. Cluster sandbox 9/9 verde. Riferimento: runtime/sandbox.py.
Stato nella sequenza dei microdesign: in valutazioneapprovatotestatoimplementato.

Indice

  1. Cos'è la sandbox
  2. API esposte
  3. Derivazione dei flag dal manifest
  4. Ripiego controllato
  5. Integrazione in agent_runtime
  6. Profili e livelli di autonomia
  7. Test
  8. Stato di bwrap nel sistema
  9. Limiti e sviluppi futuri
  10. Sandbox remota per-OS (executor su dispositivo)

1. Cos'è la sandbox

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.

manifest capability dichiarate profilo bwrap derivato invoke_executor sys.executable + autonomy esecuzione confinata rete isolata se possibile
Figura 1 — Il recinto di esecuzione: dal manifest si deriva il profilo 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.

Una sandbox come libreria, non come servizio. Niente daemon, niente socket, niente policy file da editare a parte. Tutto deriva dal manifest dell'executor: il pianificatore chiama sandbox.wrap_command(executor, cmd) e riceve indietro un comando avvolto pronto per subprocess.run. Se bwrap manca, il comando passa invariato.

2. API esposte

Il modulo runtime/sandbox.py espone quattro funzioni pubbliche. Niente classi: lo stato globale è nullo a parte la cache di shutil.which.

FunzioneCosa faCitazione
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:

3. Derivazione dei flag dal manifest

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.

3.1 Path di sistema read-only

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).

3.2 Filesystem privati

Tre montaggi obbligatori, sempre presenti:

Citazione: runtime/sandbox.py:127-129.

3.3 Codice dell'executor

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).

3.4 Capability fs:read e fs:write

Per ogni capability del manifest, il modulo guarda kind (famiglia) e mode (modalità):

CapabilityEffetto
fs:read con hintper ogni hint si calcola la cartella radice (vedi 3.5) e si aggiunge --ro-bind <path> <path>.
fs:write con hintcome sopra, ma --bind (read-write).
network:*nessun bind aggiuntivo, ma flag has_network = True (vedi 3.6).
code:execnessun 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).

3.5 Espansione degli hint

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).

3.6 Isolamento di rete

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).

3.6.1 Autorità dei provider per invocazione

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.

3.7 Isolamenti sempre attivi

Indipendentemente dal manifest, ogni sandbox include:

--unshare-user --unshare-ipc --unshare-uts --die-with-parent

Citazione: runtime/sandbox.py:170-173.

4. Ripiego controllato

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'è:

CasoComportamentoCitazione
(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.

Niente errore esplicito quando bwrap manca. È una scelta deliberata: forzare la presenza di bwrap renderebbe il sistema fragile su macchine di sviluppo, container minimali, ambienti CI. La policy di "sandbox obbligatoria" può essere imposta a livello di rilascio (controllando in boot.py che sandbox.status["active"] sia True), ma il modulo in sé non pretende.

5. Integrazione in 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".

6. Profili e livelli di autonomia

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).

7. Test

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.

#CasoCosa verifica
1status_torna_dictstatus ritorna un dict con le chiavi attese (bwrap_available, bwrap_path, disabled_via_env, active).
2wrap_command_no_bwrap_passa_invariatoQuando bwrap_available è False, wrap_command ritorna esattamente il comando di input (lista uguale).
3sandbox_disabled_rispetta_envMETNOS_SANDBOX=0 (e varianti) disabilita l'avvolgimento anche con bwrap presente.
4expand_hints_tronca_al_globHint in stile glob (es. /tmp/**) sono troncati al primo separatore glob; ~ espansa; duplicati eliminati.
5capability_kind_e_mode_parse_capability_kind e _capability_mode riconoscono nomi fs:read, network:http, code:exec; gestiscono sia dict sia stringa.
6build_bwrap_args_isola_rete_se_no_network_capManifest senza capability network:* → gli args contengono --unshare-net.
7build_bwrap_args_lascia_rete_se_network_capManifest con network:http → gli args non contengono --unshare-net.
8build_bwrap_args_bind_rw_per_fs_writeCapability fs:write con hint produce --bind; fs:read produce --ro-bind.
9build_bwrap_args_include_code_dir_roGli 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.

8. Stato di bwrap nel sistema

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.

Quando attivare. Su un server Metnos stabile conviene installare 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.

9. Limiti e sviluppi futuri

LimiteQuando 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.

Note finali

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.

10. Sandbox remota per-OS (executor su dispositivo)

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.

Un contratto solo, tante implementazioni. Ogni modulo sandbox_<os>.rs espone questa firma:
run_sandboxed(exec, python, shim, args, env, limits) -> Output
Il 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.

10.1 Un contenimento diverso per ogni sistema operativo

Ogni sistema operativo offre strumenti di isolamento diversi. Il client usa il più forte disponibile su quella piattaforma. Ecco il quadro completo.

SistemaContenimentoRobustezzaCome 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.

10.2 Windows: il Job Object come una stanza chiusa a chiave

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.

1. CreateJobObjectW crea la stanza (il Job) 2. SetInformationJobObject KILL_ON_JOB_CLOSE + tetto memoria e processi 3. CREATE_SUSPENDED processo creato, ma fermo 4. AssignProcessToJobObject metti il processo nella stanza 5. ResumeThread l'executor parte davvero 6. attesa / scadenza fino a limits.wall 7. TerminateJobObject chiudi la stanza: albero morto
Figura 2 — Ciclo di vita del Job Object su Windows. Il processo nasce fermo (3), entra nella stanza (4) e solo allora parte (5): così nulla può sfuggire prima che la stanza sia chiusa. Alla scadenza (6) si chiude la stanza (7) e l'intero albero dei processi viene spento.

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".

Scadenza onesta. Quando scatta il tempo massimo (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.

10.3 Perché il Job Object basta, per ora

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.

10.4 Come arriva Python sul dispositivo

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.

Stato. Su Windows il risultato dichiara 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.