Microprogettazione — allineata al codice al 1°. Cluster sandbox 9/9 verde. Riferimento: runtime/sandbox.py.
Stato nella sequenza dei microdesign: under approvalapprovedtestedimplemented.
← Indice documentazione Microprogettazione › sandbox

Metnos

sandbox — il guscio kernel-level attorno agli executor
Microprogettazione
Pubblico: chi vuole capire come Metnos isola gli executor dal resto del sistema.

Lettura: 10 minuti.

Indice

  1. Cos'è la sandbox
  2. API esposte
  3. Derivazione dei flag dal manifest
  4. Fallback graceful
  5. Integrazione in agent_runtime
  6. Profili e livelli di autonomia
  7. Test
  8. Stato di bwrap nel sistema
  9. Limiti e cosa è rimandato a +

1. Cos'è la sandbox

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.

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 kernel-level: 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 wrappato 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 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:

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

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 l'ancestor (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 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).

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.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. Fallback graceful

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

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

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

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

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

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 il wrapping anche con bwrap presente.
4expand_hints_tronca_al_globHint glob-like (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

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.

Quando attivare. Su 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.

9. Limiti e cosa è rimandato a +

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.

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