policy — il filtro di legalità di ogni azioneLa policy è il filtro di legalità di Metnos: lo strato 2 dell'architettura (cap. 6 dell'Architettura). Per ogni combinazione di livello di autonomia, capability e bersaglio decide un esito fra permesso, negato, approvazione richiesta. È il modulo che incarna le regole condivise fra Roberto e l'agente: cosa si può fare senza chiedere, cosa serve chiedere, cosa non si fa mai.
La policy convive con altri due strati che decidono cose diverse, e questo è il punto di chiarezza che il non aveva:
I tre filtri sono in serie: la policy gira per prima (è quella più economica, una lookup in tabella + un'eventuale query SQLite); se passa, il vaglio applica giudizio LLM contestuale; se anche il vaglio passa, la sandbox wrappa l'invocazione. Il modulo runtime/policy.py è ~360 righe, niente daemon, niente stato globale a parte la cache della tabella e il file SQLite dei grants.
Il registro è il dizionario chiuso delle azioni che Metnos riconosce. Tredici voci canoniche, definite in runtime/policy.py:CAPABILITY_REGISTRY (runtime/policy.py:46-121). Ogni voce è un CapabilitySpec con quattro attributi:
name — il nome canonico (formato famiglia:modo, es. fs:read);critical — True se l'azione è irreversibile o ad alta posta in gioco (write, send, exec);default_approval — modalità di approvazione di default: none (mai), per_target (una volta per ogni nuovo bersaglio), always (ogni volta, anche per bersagli già visti);target_kind — tipologia del bersaglio: path_glob, host, exact, none.| name | critical | default_approval | target_kind | descrizione |
|---|---|---|---|---|
fs:read | no | per_target | path_glob | lettura file dal filesystem locale entro path_glob dichiarati |
fs:write | sí | per_target | path_glob | scrittura/modifica file entro path_glob (critica) |
code:exec | sí | always | exact | esecuzione di un comando shell di una whitelist (es. pkg manager) |
network:http | no | per_target | host | HTTP/HTTPS GET/POST verso host autorizzati |
llm:local | no | none | none | chiamata LLM locale (llama-server, llama.cpp), costo zero |
llm:online | no | per_target | none | chiamata LLM online (Anthropic, OpenAI,...), costo > 0 |
mail:read | no | per_target | exact | lettura messaggi IMAP da una mailbox autorizzata |
mail:send | sí | always | exact | invio SMTP a destinatari (irreversibile, alta posta in gioco) |
channel:in | no | none | exact | ricezione messaggi da un canale (Telegram, CLI, voice) |
channel:out | no | per_target | exact | invio messaggi a un canale specifico |
time:read | no | none | none | lettura ora corrente e fusi orari |
parse:local | no | none | none | parsing locale di formati noti (PDF, HTML, JSON, CSV) |
calendar:read | no | per_target | exact | lettura eventi da un calendario autorizzato |
Il registro è chiuso: record_grant rifiuta una capability che non vi appartiene (runtime/policy.py:243-244). Aggiungere una capability significa modificare il registro nel codice e far girare i test — non c'è iscrizione a runtime. È una scelta deliberata: il vocabolario delle azioni è un asset di sicurezza, non una superficie di estensione liberamente aperta.
Tre famiglie sono critical: fs:write, code:exec, mail:send. Si tratta di azioni che modificano il mondo in modo irreversibile (un file scritto, un comando shell che ha già girato, una mail che è partita). Due fra queste hanno default_approval = always: code:exec e mail:send. Anche al massimo livello di autonomia, queste due passano sempre per una conferma esplicita.
Quattro capability hanno default_approval = none: llm:local, channel:in, time:read, parse:local. Sono azioni a costo zero, reversibili, senza effetti laterali sul mondo esterno (ascoltare un canale, leggere un'ora, fare il parse di un PDF dentro la sandbox). Restano fuori dal flusso di approvazione anche al livello più conservativo.
La tabella è il prodotto cartesiano dei tre livelli di autonomia (ReadOnly, Supervised, Full) per le 13 capability. Per ogni cella un esito: allowed, approval_required, denied. Generata in runtime/policy.py:_TABLE e _init_table (runtime/policy.py:140-171) secondo regole canoniche, non scritte a mano.
| capability | ReadOnly | Supervised | Full |
|---|---|---|---|
fs:read | approval | approval | allowed |
fs:write | denied | approval | allowed |
code:exec | denied | approval | approval |
network:http | denied | approval | allowed |
llm:local | allowed | allowed | allowed |
llm:online | denied | approval | allowed |
mail:read | approval | approval | allowed |
mail:send | denied | approval | approval |
channel:in | allowed | allowed | allowed |
channel:out | denied | approval | allowed |
time:read | allowed | allowed | allowed |
parse:local | allowed | allowed | allowed |
calendar:read | approval | approval | allowed |
La tabella non è arbitraria: nasce da tre regole, una per livello, che _init_table applica iterando sul registro.
ReadOnly. Solo capability di sola lettura, senza effetti laterali visibili al mondo esterno. Mai write, mai send, mai exec, mai LLM online (che ha un costo monetario uscente). Le capability di lettura con default_approval = per_target (fs:read, mail:read, calendar:read) restano approval_required: il livello più conservativo non rinuncia a esse, ma chiede conferma per ogni nuovo bersaglio. Le critical e le altre con default per_target o always diventano denied.
Supervised. Tutto quel che ReadOnly può, in più alza i denied a approval_required: ogni capability con default_approval ≠ none richiede approvazione. È il livello in cui il sistema è pienamente operativo ma ogni azione con effetto sul mondo passa per Roberto.
Full. Tutto allowed tranne le capability con default_approval = always: code:exec e mail:send restano approval_required. Sono le due dove un errore non si annulla, e per questo non si concedono mai senza conferma esplicita, indipendentemente dal livello di fiducia.
La tabella sola non basta. Quando Roberto approva una richiesta — "sí, scrivi pure in ~/Documents/fatture-2026/* per i prossimi due mesi" — vogliamo che il sistema ricordi quella concessione e non chieda di nuovo per ogni file salvato. La memoria di queste concessioni vive in una tabella SQLite single-file: i grants.
Definito in runtime/policy.py:SCHEMA (runtime/policy.py:186-202):
CREATE TABLE IF NOT EXISTS grants ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel TEXT NOT NULL, sender_id TEXT NOT NULL, capability TEXT NOT NULL, target TEXT NOT NULL, granted_at TEXT NOT NULL, expires_at TEXT, granted_by TEXT, revoked_at TEXT );
Una concessione è identificata dalla quaterna (channel, sender_id, capability, target): chi ha approvato (es. Telegram + utente Roberto), per quale azione, su quale bersaglio. Le date granted_at/expires_at/revoked_at sono ISO 8601 UTC. Due indici accelerano le due query tipiche: lookup esatto e scansione dei grants attivi.
Path del file: ~/.local/state/metnos/grants.db, override possibile via variabile d'ambiente METNOS_GRANTS_DB (runtime/policy.py:27, 223-229). La cartella padre viene creata al primo accesso.
| funzione | cosa fa | citazione |
|---|---|---|
record_grant(channel, sender_id, capability, target, expires_at=None, granted_by=None) |
Registra una concessione. Solleva ValueError se la capability non è nel registro. Ritorna l'oggetto Grant con id assegnato. |
runtime/policy.py:232-259 |
has_grant(channel, sender_id, capability, target) |
True se esiste un grant attivo (non revocato, non scaduto) per la quaterna. La query confronta expires_at con il tempo corrente. |
runtime/policy.py:262-284 |
list_grants(channel=None, sender_id=None, include_revoked=False) |
Lista i grants, filtrabile per canale/sender ed eventualmente includendo i revocati. Ordinati per granted_at discendente. |
runtime/policy.py:287-311 |
revoke_grant(grant_id) |
Pone revoked_at al momento corrente. Ritorna True se è stato modificato qualcosa, False se il grant era già revocato o non esisteva. |
runtime/policy.py:314-325 |
Tutte e quattro le funzioni aprono e chiudono la connessione per chiamata: nessuno stato in memoria. Il throughput non è il punto — siamo nel dominio di poche query al secondo — e l'isolamento per chiamata semplifica il ragionamento sui test.
Il modulo policy è read-only dal pianificatore: quest'ultimo legge la tabella e i grants, ma non li crea mai. La creazione avviene nel dispatcher di approval (vedi approval_ux): quando una pending request risolve come approved con scope this and similar, il dispatcher chiama record_grant e da quel momento la concessione è persistente.
effective_outcome)
La funzione effective_outcome (runtime/policy.py:330-355) è il punto di accesso unico per il pianificatore. Combina tabella e grants in un unico esito, secondo quattro casi:
| tabella dice | grant attivo per (channel, sender, target)? | esito |
|---|---|---|
| allowed | indifferente, non si interroga il DB | allowed |
| denied | indifferente, non si interroga il DB | denied |
| approval_required | sí | allowed |
| approval_required | no (oppure parametri di scope mancanti) | approval_required |
La logica è lineare: se la tabella decide già in modo netto (allowed o denied), il grant non viene neppure consultato; se decide approval_required, un grant attivo lo trasforma in allowed, altrimenti resta approval_required.
effective_outcome_denied_non_e_alzato_da_grant verifica esattamente questo invariante (cap. 8).
fs:write su ~/Documents/fatture-2026/*. L'agente sta per salvare ~/Documents/fatture-2026/04-Acme.pdf.
approval_required. Grant attivo per (telegram, roberto, fs:write, ~/Documents/fatture-2026/*) → esito allowed. Nessuna carta di approvazione, salvataggio diretto.
denied. Anche se il grant esistesse, il DB non viene interrogato. Esito denied: l'agente segnala che il livello attivo non permette scrittura su disco e suggerisce di salire a Supervised.
mail:send, destinatario [email protected] a cui Roberto ha mandato dieci mail in passato.
approval_required (regola "always"). Grant attivo? Per mail:send la modalità di default è always, e in nessun grant viene mai creato per le capability always. Esito approval_required: la mail va in coda, Roberto vede la carta, approva o nega quella singola mail.
In runtime/policy.py è un modulo separato, completo e testato. L'integrazione nel pianificatore è in corso. Lo schema previsto:
def execute_step(step, ctx): cap = step.capability # es. "fs:write" target = step.target # es. "/home/user/Documents/fatture-2026/04.pdf" outcome = policy.effective_outcome( ctx.autonomy_level, # "ReadOnly" | "Supervised" | "Full" cap, channel=ctx.channel, # es. "telegram" sender_id=ctx.sender_id, # es. "roberto" target=target, ) if outcome == "denied": return Refused(reason="livello insufficiente per " + cap) if outcome == "approval_required": pending = approval_registry.create_pending(step, ctx) channels.approval.render_approval_card(pending, ctx.channel) return Awaiting(pending_id=pending.id) # outcome == "allowed" return invoke_executor(step.executor, step.args, autonomy=ctx.autonomy_level)
Tre rinvii a moduli esistenti:
approval_registry.create_pending mette in coda la richiesta e restituisce un id;channels.approval.render_approval_card compone la carta a tre righe e la spedisce sul canale d'origine (vedi approval_ux);invoke_executor è lo stesso punto in cui la sandbox wrappa il comando; il parametro autonomy è già pass-through (cap. 6 di sandbox.html).
Il pianificatore non legge mai direttamente la tabella o i grants: fa una sola chiamata a effective_outcome e ramifica sull'esito. Questo mantiene policy come unico punto di verità sulle regole; il giorno in cui le regole cambieranno (: cost tier, rate limit), il pianificatore non si tocca.
Il modulo è eseguibile come script (runtime/policy.py:360-423): utile per ispezione manuale e per costruire dashboard in poche righe. Quattro sottocomandi.
| comando | cosa fa |
|---|---|
python3 -m policy registry | stampa una riga JSON per ognuna delle 13 capability con tutti i suoi attributi. |
python3 -m policy table | stampa tre righe JSON, una per livello (ReadOnly/Supervised/Full), con l'esito per ognuna delle 13 capability. |
python3 -m policy check <level> <capability> [--channel C --sender S --target T] | stampa l'esito di effective_outcome. Senza --channel/--sender/--target ritorna l'esito da sola tabella. |
python3 -m policy grants [--channel C] [--sender S] [--all] | lista grants, attivi di default, tutti con --all. |
python3 -m policy revoke <grant_id> | revoca un grant per id; stampa revoked o no-op. |
L'output JSON-line agevola la pipe verso jq: ad esempio python3 -m policy table | jq mostra la matrice in formato leggibile.
Cluster policy nel framework di test del runtime: 10/10 verde alla data. I casi coprono il registro, le tre regole della tabella, il round-trip dei grants e l'invariante di sicurezza che separa denied da grants alzabili.
| # | caso | cosa verifica |
|---|---|---|
| 1 | registry_contiene_13_capability_canoniche | CAPABILITY_REGISTRY espone esattamente le 13 voci attese, ognuna con i quattro attributi obbligatori. |
| 2 | is_allowed_readonly_blocca_write_e_exec [security] | al livello ReadOnly: fs:write, code:exec, mail:send sono denied. |
| 3 | is_allowed_supervised_richiede_approval_per_critical | al livello Supervised: fs:write è approval_required; llm:local è allowed; mail:send e code:exec sono approval_required. |
| 4 | is_allowed_full_mantiene_always_per_critical_irreversibili [security] | al livello Full: mail:send e code:exec restano approval_required; fs:write diventa allowed. |
| 5 | record_grant_e_has_grant_round_trip | dopo record_grant sulla quaterna (channel, sender, capability, target), has_grant sulla stessa quaterna ritorna True. |
| 6 | record_grant_capability_sconosciuta_solleva | record_grant con una capability fuori dal registro solleva ValueError. |
| 7 | revoke_grant_disattiva_has_grant | dopo revoke_grant(id), has_grant sulla stessa quaterna ritorna False. |
| 8 | effective_outcome_grant_alza_a_allowed | se la tabella dice approval_required e un grant attivo esiste, effective_outcome ritorna allowed. |
| 9 | effective_outcome_denied_non_e_alzato_da_grant [security] | se la tabella dice denied, l'esistenza di un grant qualsiasi non altera l'esito: resta denied. |
| 10 | list_grants_filtra_per_canale_e_revoked | list_grants rispetta i filtri per canale/sender ed esclude di default i revocati. |
I tre casi marcati [security] sono gli invarianti che vincolano l'evoluzione futura del modulo: una qualsiasi modifica che li rompa è uno smantellamento della postura di sicurezza, non un refactor.
| limite | quando si toglie |
|---|---|
| Niente rate-limit nel codice. La tabella autonomy × capability non distingue ancora "una chiamata vs dieci chiamate al minuto". Una capability concessa è concessa senza tetto; gli abusi accidentali (loop fuori controllo, executor che chiama HTTP cento volte) non vengono frenati a livello di policy. | , con un token bucket per_capability salvato nello stesso DB dei grants. Il token bucket richiede di scegliere bene i parametri (capacità, rate di ricarica) per ogni capability; serve telemetria d'uso reale prima di fissarli. |
Niente cost tier. llm:online richiede approval_required generico, non differenzia "Sonnet a $0.02 per chiamata" da "GPT-5 a $0.17". Roberto vede tutte le chiamate online uguali, anche quando il costo varia di un ordine di grandezza. |
, quando il giudice LLM avrà misurato il fattore costo nel reward del vaglio. La policy potrà allora esporre soglie esplicite (es. "fino a $0.05 allowed in Full, sopra approval_required"). |
| Niente policy custom per profilo utente. La tabella è compilata in codice. Un override per profilo utente (es. Roberto vs un familiare con livelli diversi sulle stesse capability) richiede un layer di config che non esiste ancora. | Quando il pairing supporterà profili multipli con sender_id distinti che mappano a tabelle diverse. Si introdurrà un layer TOML letto al boot (tipo policy_overrides.toml) ma con vincoli di integrità verso il registro. |
Niente revoke automatico. I grants scadono solo via expires_at esplicito. Una "scadenza per inattività" (es. 90 giorni di non uso) non esiste; i grants restano nel DB anche per anni. |
Quando il pool di grants attivi diventerà abbastanza grande da rendere utile la pulizia. Implementazione semplice: un cron interno che, al boot, cerca grants senza accessi (servirebbe una colonna last_used) o con granted_at molto vecchio e li revoca con motivazione "stale". |
La policy è un modulo piccolo per scelta: tutta la complessità del filtro di legalità vive in due strutture leggibili al colpo d'occhio (un dizionario di 13 voci, una tabella 3×13) più un round-trip SQLite per i grants. Niente DSL, niente regole espresse in linguaggio naturale, niente runtime configuration che si può rompere caricando un file mal formato.
Il vincolo che un grant non possa mai elevare un denied a allowed è il cuore della separazione fra il piano tattico (concessione mirata, dentro un livello che la consente) e il piano strategico (il livello stesso, scelto a freddo nel pairing). Tenere quei due piani distinti è ciò che permette a Metnos di essere scalabile in autonomia senza scivolare verso più permessi del previsto.