Come Metnos decide se un'azione proposta dal modello puo' diventare effetto sul mondo. Il vaglio e' il valutatore costituzionale: gira fra la proposta di un tool_call e la sua esecuzione, in due fasi distinte — una guardia binaria deterministica e un giudice graduato allineato ai telos. Include hook dedicati come check_cross_user_send (host gatekeeper, self-send permesso, guest→altri richiede vaglio) e lo short-circuit sui verbi sola-lettura. Riferimento di implementazione: runtime/vaglio.py.
← Indice documentazione Microprogettazione › vaglio

Metnos

vaglio — il valutatore costituzionale in due fasi
Microprogettazione — il valutatore costituzionale in due fasi.
Pubblico: chi vuole capire come Metnos decide se un'azione proposta dal LLM puo' diventare effetto sul mondo.

Lettura: 12 minuti.

Indice

  1. Cos'e' il vaglio
  2. Anatomia: il Verdict
  3. Guardia binaria
  4. Giudice graduato
  5. Giudice LLM (opt-in)
  6. Logging JSONL
  7. Integrazione runtime
  8. Test
  9. Limiti noti
  10. Configurabili

1. Cos'e' il vaglio

Il vaglio — due fasi, dal tool_call all'esito tool_call proposto dal LLM Fase 1 — guardia forbidden path + shell pericoloso short-circuit verbo ∈ SAFE_VERBS (sola-lettura) Fase 2 — giudice rule-based / LLM score vs soglia 0.30 APPROVED BLOCKED viola → BLOCKED (blocked_by=guard) match → APPROVED (judge_kind=safe-verb-shortcut) score < soglia → BLOCKED (blocked_by=judge) La guardia precede sempre tutto; il giudice gira solo se la guardia passa e il verbo non è sola-lettura.

Fra il momento in cui il LLM dice «voglio chiamare shell_exec con questi argomenti» e il momento in cui il runtime esegue il processo, c'e' un passaggio obbligato: il vaglio. Decide se quella proposta puo' diventare azione. Non e' un'opinione, e' un filtro: se il vaglio nega, il piano non procede.

Il vaglio applica due fasi distinte, e questa distinzione e' la prima cosa che vale la pena capire. La prima fase e' una guardia binaria: c'e' un nucleo di violazioni che non si discutono — toccare ~/.ssh, eseguire rm -rf /, scrivere su /etc/passwd. Se uno di questi pattern compare, l'azione e' negata e basta. Niente sfumature, niente punteggio, niente «ma il contesto». La seconda fase e' un giudice graduato: misura quanto l'azione e' allineata ai telos dell'utente in [0, 1]. Sotto soglia, negata. Sopra, approvata.

Perche' due fasi separate? Perche' altrimenti accade un fenomeno specifico, descritto nel cap. 11.1 dell'Architettura come auto-conferma del modello: se metti deontologia (cosa e' lecito) e teleologia (cosa serve all'utente) in un solo punteggio, un giudice teleologicamente convinto tendera' a giustificare anche cio' che dovrebbe essere bloccato a monte. «Si', sto cancellando /etc, ma in fondo l'utente voleva pulizia.» Tenendole separate, la guardia chiude la porta prima che il giudice possa razionalizzare.

La separazione e' anche una promessa di stabilita' architettonica: la guardia e' una lista di regex codificate; quando il giudice gira come chiamata LLM (modo opt-in, cap. 5), la guardia resta la stessa. Il nucleo non negoziabile non passa attraverso un modello.

Nota di lessico. Il termine vaglio ha senso letterale: setaccio. Non un giudicante, non un revisore. Un setaccio fa due cose contemporaneamente: trattiene cio' che non deve passare e lascia passare cio' che deve. Le due fasi sono due maglie diverse della stessa rete.

2. Anatomia: il Verdict

Ogni chiamata al vaglio restituisce un oggetto Verdict. E' un dataclass piccolo (runtime/vaglio.py:74-81):

@dataclass
class Verdict:
 approved: bool
 reason: str
 ts: float = field(default_factory=time.time)
 judge_kind: str = "rule-based-v1"
 score: float = 1.0 # rilevante solo se la guardia ha lasciato passare
 blocked_by: str | None = None # "guard" | "judge" | None
CampoTipoSignificato
approvedboolEsito finale: True se l'azione puo' procedere, False altrimenti.
reasonstrSpiegazione leggibile. Per il blocco da guardia: "guard: forbidden path violato:...". Per il blocco da giudice: "judge: score 0.20 < soglia 0.30 (...)".
tsfloatTimestamp Unix della decisione.
judge_kindstrFamiglia del giudice usato. "rule-based-v1" (default), "llm-v1" (opt-in via env METNOS_JUDGE_KIND=llm-v1) oppure "safe-verb-shortcut" per i verbi sola-lettura approvati senza giudice.
scorefloatPunteggio di allineamento in [0, 1]. Significativo solo se la guardia ha lasciato passare. Per blocchi di guardia, vale 0.0.
blocked_bystr|NoneQuale fase ha negato: "guard", "judge", oppure None se approvato.

L'orchestratore judge(intent, executor_name, args, context) (runtime/vaglio.py:186-214) e' la sola firma pubblica. Riceve l'intent dell'utente (la query iniziale del turno), il nome dell'executor proposto, gli args validati, e un context dizionario opzionale (mode, capability, step number). Restituisce un singolo Verdict.

3. Guardia binaria

La guardia non ha sfumature. Compie due controlli in sequenza, e ferma alla prima violazione (runtime/vaglio.py:104-126).

Controllo 1: forbidden paths

La guardia estrae tutti i valori stringa dagli args, anche annidati (_flatten_str_values, runtime/vaglio.py:86-97), espande la tilde con il path utente (_expand_user, runtime/vaglio.py:100-101), e li confronta con una lista di pattern regex codificate (runtime/vaglio.py:45-58):

_FORBIDDEN_PATH_PATTERNS = [
 re.compile(r"(^|/)\.ssh(/|$)"),
 re.compile(r"^/etc/(passwd|shadow|sudoers)"),
 re.compile(r"^/etc/ssh(/|$)"),
 re.compile(r"^/root(/|$)"),
 re.compile(r"^/boot(/|$)"),
 re.compile(r"^/sys(/|$)"),
 re.compile(r"^/proc(/[0-9]|$)"),
 re.compile(r"^/dev/(sd|nvme|mmcblk|loop)"),
 re.compile(r"\.aws/credentials"),
 re.compile(r"\.config/[^/]+/credentials\.env"),
 re.compile(r"\.gnupg(/|$)"),
]

Il punto sottile: la guardia non distingue fra «sto leggendo ~/.ssh/id_rsa» e «sto solo passando la stringa ~/.ssh/id_rsa in un parametro qualsiasi». Se il pattern compare, blocca. Questo e' deliberato: significa che anche un executor che menziona un path proibito in args (per esempio in un campo "glob" o "exclude") viene fermato. Conservatore? Si'. Ma il costo di un falso positivo e' una richiesta riformulata; il costo di un falso negativo e' una chiave SSH che esce.

La lista non si rilassa per livello di autonomia. Il config permette di giocare su molte cose (timeout, soglie, modalita'). Sui forbidden path, niente: e' il «nucleo non negoziabile» del cap. 5 dell'Architettura. Modificarli richiede edit del codice e nuovo deploy. Deliberato.

Controllo 2: comandi shell quasi-irrecuperabili

Si applica solo se executor_name == "shell_exec" oppure se il context dichiara capability == "code:exec" (runtime/vaglio.py:117-124). Il valore controllato e' il campo command (o in alternativa cmd); se e' una lista, viene unita con spazi prima del match. La lista di pattern (runtime/vaglio.py:62-69):

_DANGEROUS_SHELL_PATTERNS = [
 re.compile(r"\brm\s+-rf?\s+/(\s|$)"), # rm -rf /
 re.compile(r"\brm\s+-rf?\s+~(\s|$|/)"), # rm -rf ~
 re.compile(r"\bmkfs\b"),
 re.compile(r"\bdd\s+.*\bof=/dev/"),
 re.compile(r":\s*\(\s*\)\s*\{\s*:\|:&\s*\}"), # fork bomb
 re.compile(r"\bchmod\s+-?R?\s*0?77[0-9]\s+/"),
]

La motivazione e' la Legge 1 della constitution: «niente stato irrecuperabile». Una rm -rf / non si annulla con un undo; un mkfs sull'unita' di sistema neppure; una fork bomb satura il processo. Sono patologie distinte da una qualunque cancellazione di file: tolgono il sistema da uno stato in cui il sistema stesso possa ancora rispondere.

Anche qui, il match e' testuale. La guardia non emula la shell: cerca pattern letterali nel comando. Significa che varianti astutamente con caratteri di escape possono passare; la difesa di profondita' e' lo sandbox con namespacing dei file system per shell_exec, di cui la guardia e' solo il primo strato.

4. Giudice graduato

Se la guardia lascia passare, parte il giudice. Per default il giudice e' rule-based: nessuna chiamata LLM, microsecondi di latenza, costo zero. La funzione e' judge_score(intent, executor_name, args, context) e restituisce (score, rationale).

Heuristiche

Si parte da uno score base di 0.7: il regime rule-based e' benevolente per costruzione (vedi cap. 9). Da li' il giudice somma o sottrae piccoli aggiustamenti, e poi clampa in [0, 1].

SegnaleEffetto su scoreRazionale
L'intent dell'utente menziona il nome dell'executor (anche solo un token di esso, lunghezza ≥ 3).+0.10Segno di intento esplicito. Se l'utente ha scritto «read the file» e l'executor e' read_files, il match su read e' un debole ma reale segnale che l'azione corrisponde alla richiesta.
Uno qualunque dei valori stringa in args contiene .. e / (possibile path traversal).−0.20Heuristica grossa ma efficace: molti attacchi su filesystem usano .. per uscire dalla cartella attesa. La penalita' e' la piu' grande perche' indica tentativo di scavalcare un confine.
Una chiave di args non rispetta l'identifier Python (caratteri non alfanumerici / underscore).−0.10Anomalia di forma. Un executor sano dichiara args con chiavi normali; una chiave strana suggerisce o injection o errore di pianificazione.
Il context dichiara critical: false (executor non-critico, sola lettura).+0.05Bonus modesto: gli executor a sola lettura non possono produrre stato irrecuperabile, quindi la barriera puo' essere leggermente piu' bassa.

Lo score e' clampato in [0, 1] e confrontato con JUDGE_THRESHOLD (runtime/vaglio.py:40). Default: 0.30, configurabile via env METNOS_JUDGE_THRESHOLD.

Esempio: path innocuo, intent generico

Intent: "leggi le mie note in /tmp/n.txt". Executor: read_files. Args: {"paths": ["/tmp/n.txt"]}.

Score di partenza: 0.7. Il giudice spezza read_files sui separatori, ottiene i token read e files; in italiano nessuno dei due compare nell'intent. Nessun bonus di intent. Path innocuo. Chiavi pulite.

Score finale: 0.7. Sopra soglia. approved=True, reason="approved: score 0.70 (score base)".

Esempio: path traversal sospetto

Args: {"path": "/tmp/../etc/foo"}. Path traversal sospetto: .. e / presenti.

Score: 0.7 − 0.2 = 0.5. Ancora sopra 0.30: approvato lo stesso, ma con la nota "possibile path traversal ('..' in path)" nel reason e nel log.

Per bloccare effettivamente l'azione, il giudice richiederebbe altri segnali negativi cumulativi, oppure una soglia configurata piu' alta. Il giudice rule-based marca; non blocca da solo se non in casi limite.

Perche' il giudice rule-based e' cosi' benevolente? Perche' il vero filtro e' la guardia. Il giudice rule-based e' un primo strato di osservabilita' (cosa lascia odore strano?) piu' che un cancello. Lo score finisce nel log JSONL e diventa segnale per analisi successive; gli outlier emergono. Con il giudice LLM (opt-in) la soglia ha senso piu' stringente, perche' la valutazione puo' davvero pesare alternative e contesto.

5. Giudice LLM (opt-in)

Il vaglio puo' usare un giudice basato su LLM al posto di quello rule-based. Si attiva via env METNOS_JUDGE_KIND=llm-v1 (default rule-based-v1): e' opt-in perche' costa — ogni decisione del giudice paga una chiamata al tier middle. Implementazione in runtime/vaglio.py:_judge_score_llm.

5.1 Decisioni applicate

5.2 Quando attivarlo

Il rule-based e' il default e basta nella maggior parte dei turni: la guardia filtra il pericolo, il giudice rule-based segnala outlier. Il giudice LLM serve quando si vuole una valutazione piu' fine dell'allineamento ai telos (es. azioni proattive con bother budget, scelta tra opzioni equivalenti). Si attiva con:

METNOS_JUDGE_KIND=llm-v1 python3 -m channels.daemon

Tradeoff: ogni chiamata al giudice paga una latenza dell'ordine dei secondi (Qwen 3.6 35B‑A3B locale, tier middle) o inferiore con il tier frontier (Anthropic Opus 4.7, opt-in). Per un turno di 3 step, sono 3 chiamate al vaglio. Una policy di costo futura potra' degradare automaticamente al rule-based sopra una soglia di chiamate/giorno.

5.3 Sviluppi successivi

6. Logging JSONL

Ogni chiamata al vaglio scrive una riga JSONL in ~/.local/share/metnos/vaglio/YYYY-MM.jsonl (file mensile, vedi runtime/vaglio.py:33 e 176-183). La scrittura e' fail-safe: se il filesystem nega, il vaglio non blocca la decisione — semplicemente non scrive il log.

Schema record

{
 "approved": false,
 "reason": "guard: forbidden path violato: pattern '(^|/)\\.ssh(/|$)' in args",
 "ts": 1714225863.214,
 "judge_kind": "rule-based-v1",
 "score": 0.0,
 "blocked_by": "guard",
 "intent": "leggi la chiave",
 "executor": "read_files",
 "args_keys": ["path"],
 "context_keys": ["mode", "step"]
}
Solo le chiavi di args, non i valori. Questo e' esplicito (runtime/vaglio.py:197 e 212): nel log finiscono i nomi delle chiavi ("path", "command",...) ma non i valori (il path effettivo, il comando effettivo). Motivo: la privacy. I valori spesso contengono dati sensibili (path utente, contenuti di file, payload), e un log che li riportasse diventerebbe esso stesso un asset da proteggere. Le chiavi bastano per analisi statistica (quali executor vengono bloccati piu' spesso?); il valore vivo si trova negli eventi del turno dell'agent_runtime con regole diverse di retention.

La rotazione e' mensile per file. Niente compressione, niente cifratura: i log restano sotto ~/.local dell'utente. Se serve archiviazione di lungo periodo, e' compito di processi esterni (rsync, backup, ecc.).

7. Integrazione runtime

Il punto di chiamata vive nel pianificatore, in runtime/agent_runtime.py:512-526. La sequenza di filtri e' rigorosamente ordinata:

# runtime/agent_runtime.py:512-526 (estratto)
# Validazione, sandbox, vaglio
validation = validate_args(args, executor.args_schema)
step.validation_failures = validation
if validation:
 obs = {"ok": False, "error": f"validation failed: {validation}"}
else:
 scope_violation = check_hints(args, executor.capabilities)
 step.scope_violation = scope_violation
 if scope_violation:
 obs = {"ok": False, "error": scope_violation}
 else:
 verdict = judge(user_query, chosen_name, args,
 {"mode": chosen_mode, "step": step_num})
 step.vaglio_approved = verdict.approved
 if not verdict.approved:
 obs = {"ok": False, "error": f"vaglio rifiuta: {verdict.reason}"}
 else:...
 obs = invoke_executor(executor, args)

L'ordine non e' casuale:

  1. Validazione args contro lo schema dell'executor (tipi e required). Se fallisce, niente vaglio: l'errore e' di forma, non di lecito.
  2. Check capabilities (check_hints): l'executor sta uscendo dal suo perimetro dichiarato? (Esempio: un executor di sola lettura che scrive.)
  3. Vaglio (judge(...)): solo se i due controlli precedenti passano. Riceve l'intent originale dell'utente (user_query), il nome dell'executor scelto, gli args validati, e un context con mode e step_num.
  4. Invoke executor: solo se il vaglio approva.

Per piani multistep, il vaglio gira fra uno step e l'altro: ogni tool_call proposto dal LLM viene vagliato prima dell'esecuzione, in modo che un piano in 5 passi produca 5 verdetti distinti. Per single-shot (un piano interamente proposto in un colpo, senza step intermedi), il vaglio gira post-hoc: il piano e' gia' formulato, ma il vaglio puo' ancora bloccare l'esecuzione di ciascun tool_call.

Il flag step.vaglio_approved finisce nel log del turno (agent_runtime cap. 11), e quindi nello spettatore: l'utente puo' vedere a posteriori quali step sono stati vagliati e con quale esito.

8. Test

Il cluster vaglio e' verde. I casi sono dichiarati in runtime/testing/populate_cases.py (sezione --- vaglio ---) e coprono guardia, giudice, log e privacy.

CasoCategoriaCosa verifica
approva_path_innocuohappyUn path normale (/tmp/x) passa, judge_kind e' "rule-based-v1", score in [0, 1].
guard_blocca_sshsecurity~/.ssh/id_rsa bloccato dalla guardia con blocked_by="guard" e ragione "forbidden path".
guard_blocca_etc_passwdsecurity/etc/passwd bloccato.
guard_blocca_credentials_usersecurity~/.config/metnos/credentials.env bloccato (controllo del pattern credentials.env annidato in path utente).
guard_blocca_rm_rf_rootsecurityshell_exec con rm -rf / bloccato come irrecuperabile.
guard_blocca_fork_bombsecurityshell_exec con :{ :|:& };: bloccato.
guard_lascia_passare_shell_innocuahappyshell_exec con ls -la /tmp passa la guardia.
judge_intent_menziona_executor_alza_scorehappyScore con intent che cita l'executor > score con intent generico.
judge_path_traversal_abbassa_scoreedgePath con .. riceve score piu' basso del path innocuo.
judge_sotto_soglia_bloccafailureCon METNOS_JUDGE_THRESHOLD=0.99, qualunque azione viene negata con blocked_by="judge".
log_jsonl_viene_scrittohappyDopo judge(...), il file YYYY-MM.jsonl esiste e contiene il marker dell'intent.
args_keys_loggate_non_valuessecurityUn valore sensibile in args ("PASSWORD_segreto_...") NON compare nel log; solo il nome della chiave ("path") e' presente.

A questi si aggiunge l'integrazione nel cluster agent_runtime, che esercita il vaglio nel suo punto di chiamata reale (validazione + scope + vaglio + invoke), su scenari end-to-end.

9. Limiti noti

LimiteSpiegazioneQuando si toglie
Il vaglio non legge il mnestoma.Separazione di concerns: il vaglio valuta solo l'intento dichiarato + executor + args, non guarda lo storico. Non può vedere «questa stessa azione e' stata negata 5 minuti fa» o «l'utente di solito non opera in questo modo».Evoluzione: il giudice LLM ricevera' contesto dal mnestoma (rilevanza simile, esiti precedenti) per pesare meglio.
Il dispatcher dei callback dell'approval_ux non rinietta nel turno vivo.La carta che l'utente vede nei canali (Telegram, Slack, ecc.) si genera via runtime/channels/approval.py. Se l'utente preme «Concedo per stavolta», il callback non rientra nel piano vivo.Evoluzione: dispatcher di callback con re-injection nel turno corrente.
Il giudice rule-based e' molto benevolente.Soglia di default 0.30 e score base 0.7: anche con piu' penalita' moderate, il punteggio resta sopra. Conscio: il vero filtro e' la guardia, il giudice marca solo gli outlier nel log.Con il giudice LLM: la soglia avra' significato piu' forte e la distribuzione degli score sara' piu' larga.
Niente spiegazione strutturata del rifiuto all'utente.Quando il vaglio nega, il pianificatore mostra la reason testuale ma non costruisce una carta «perche' ho detto no e cosa puoi fare». La UX di rifiuto e' minima.Evoluzione: integrazione con approval_ux per rifiuti dialogici.
Niente vaglio interattivo.Il vaglio e' una funzione pura: input args, output Verdict. Non puo' chiedere «sei sicuro?» o richiedere conferma.Evoluzione: integrazione con il dialog manager per richieste di autorizzazione (vedi approval_ux).

10. Configurabili

La configurazione del vaglio e' minimale, e questa minimalita' e' deliberata.

CosaDoveDefaultNote
METNOS_JUDGE_THRESHOLDVariabile d'ambiente.0.30Sotto questa, il giudice nega. Letta a import time del modulo (runtime/vaglio.py:40): per cambiarla a runtime serve importlib.reload(vaglio).
VAGLIO_LOG_DIRCostante modulo (non env).~/.local/share/metnos/vaglio/Per cambiare destinazione log servirebbe edit del codice. Volutamente non e' un parametro: il log e' un'invariante del sistema, non una preferenza utente.
_FORBIDDEN_PATH_PATTERNSCostante modulo, codificata.11 regex (vedi cap. 3).Non configurabili. Modificarle richiede edit codice e nuovo deploy. E' deliberato: cap. 5 dell'Architettura, «nucleo non negoziabile».
_DANGEROUS_SHELL_PATTERNSCostante modulo, codificata.6 regex (vedi cap. 3).Non configurabili. Stesso ragionamento: la lista dei comandi shell quasi-irrecuperabili non si rilassa.
Volutamente poche configurazioni. Quando il vaglio si configurera' di piu' (con il giudice LLM ci sono il provider, il prompt, il budget, eventualmente il fallback), ogni nuovo bottone va giustificato. La filosofia: il filtro etico ha cosi' poche manopole apposta. Cio' che non si puo' configurare, non si puo' nemmeno sbagliare.

Note finali

Il vaglio e' un componente piccolo (~210 righe di Python) ma architettonicamente centrale. La forma a due fasi e' il prezzo della robustezza: una guardia binaria che non si discute, un giudice graduato che evolve. La guardia di oggi proteggera' anche dal giudice di domani, perche' si sara' dimostrata stabile attraverso versioni del giudice.

La microprogettazione di questo modulo e' convalidata dal codice prima del documento: questo doc descrive cio' che gira, non viceversa. Il modulo ha una batteria di test propri piu' l'integrazione end-to-end nel cluster agent_runtime.