vaglio — il valutatore costituzionale in due fasi
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.
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
| Campo | Tipo | Significato |
|---|---|---|
approved | bool | Esito finale: True se l'azione puo' procedere, False altrimenti. |
reason | str | Spiegazione leggibile. Per il blocco da guardia: "guard: forbidden path violato:...". Per il blocco da giudice: "judge: score 0.20 < soglia 0.30 (...)". |
ts | float | Timestamp Unix della decisione. |
judge_kind | str | Famiglia 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. |
score | float | Punteggio di allineamento in [0, 1]. Significativo solo se la guardia ha lasciato passare. Per blocchi di guardia, vale 0.0. |
blocked_by | str|None | Quale 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.
La guardia non ha sfumature. Compie due controlli in sequenza, e ferma alla prima violazione (runtime/vaglio.py:104-126).
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.
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.
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.
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).
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].
| Segnale | Effetto su score | Razionale |
|---|---|---|
| L'intent dell'utente menziona il nome dell'executor (anche solo un token di esso, lunghezza ≥ 3). | +0.10 | Segno 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.20 | Heuristica 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.10 | Anomalia 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.05 | Bonus 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.
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)".
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.
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.
_LLM_JUDGE_SYSTEM), niente memoria
condivisa con il proponente. Difesa esplicita contro l'auto-conferma
del modello: se il proponente potesse ascoltare le proprie
giustificazioni come input del giudice, le ottimizzerebbe a
ritroso. Sessioni separate spezzano il loop.args, non i valori — stessa
disciplina del log JSONL. Anche se il LLM di middle e' remoto,
nessun valore sensibile dell'utente (path, password, contenuti) lo
raggiunge.{"score": float [0,1], "reason": "max 25 parole"}.
Parser robusto regex-based: se il LLM produce JSON non valido,
fallback a 0.5 con motivazione "llm-judge fallback (parse
fail)".LLMRouter non
e' configurato o il tier middle non risponde, il giudice ritorna
score 0.5 con motivazione esplicita "llm-judge fallback (router
missing|chat failed)". La sequenza non si blocca; il log
rivela che l'analisi e' stata degradata.JUDGE_KIND=llm-v1 attivo, la guardia binaria viene
eseguita per prima: forbidden path o shell pericoloso bloccano
senza neppure consultare il LLM. Garanzia: nessuna chiamata LLM
spesa per casi gia' decidibili da regole locali (test
judge_llm_guardia_blocca_prima_di_chiamare_llm).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.
METNOS_JUDGE_THRESHOLD
e' calibrato per il rule-based (default 0.30); il LLM tendera' a
produrre score in distribuzione diversa, e dovra' avere la sua
soglia separata.
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.
{
"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"]
}
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.).
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:
check_hints): l'executor sta uscendo dal suo perimetro dichiarato? (Esempio: un executor di sola lettura che scrive.)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.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.
Il cluster vaglio e' verde. I casi sono dichiarati in runtime/testing/populate_cases.py (sezione --- vaglio ---) e coprono guardia, giudice, log e privacy.
| Caso | Categoria | Cosa verifica |
|---|---|---|
approva_path_innocuo | happy | Un path normale (/tmp/x) passa, judge_kind e' "rule-based-v1", score in [0, 1]. |
guard_blocca_ssh | security | ~/.ssh/id_rsa bloccato dalla guardia con blocked_by="guard" e ragione "forbidden path". |
guard_blocca_etc_passwd | security | /etc/passwd bloccato. |
guard_blocca_credentials_user | security | ~/.config/metnos/credentials.env bloccato (controllo del pattern credentials.env annidato in path utente). |
guard_blocca_rm_rf_root | security | shell_exec con rm -rf / bloccato come irrecuperabile. |
guard_blocca_fork_bomb | security | shell_exec con :{ :|:& };: bloccato. |
guard_lascia_passare_shell_innocua | happy | shell_exec con ls -la /tmp passa la guardia. |
judge_intent_menziona_executor_alza_score | happy | Score con intent che cita l'executor > score con intent generico. |
judge_path_traversal_abbassa_score | edge | Path con .. riceve score piu' basso del path innocuo. |
judge_sotto_soglia_blocca | failure | Con METNOS_JUDGE_THRESHOLD=0.99, qualunque azione viene negata con blocked_by="judge". |
log_jsonl_viene_scritto | happy | Dopo judge(...), il file YYYY-MM.jsonl esiste e contiene il marker dell'intent. |
args_keys_loggate_non_values | security | Un 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.
| Limite | Spiegazione | Quando 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). |
La configurazione del vaglio e' minimale, e questa minimalita' e' deliberata.
| Cosa | Dove | Default | Note |
|---|---|---|---|
METNOS_JUDGE_THRESHOLD | Variabile d'ambiente. | 0.30 | Sotto questa, il giudice nega. Letta a import time del modulo (runtime/vaglio.py:40): per cambiarla a runtime serve importlib.reload(vaglio). |
VAGLIO_LOG_DIR | Costante 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_PATTERNS | Costante 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_PATTERNS | Costante modulo, codificata. | 6 regex (vedi cap. 3). | Non configurabili. Stesso ragionamento: la lista dei comandi shell quasi-irrecuperabili non si rilassa. |
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.