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 questa è la separazione di responsabilità che tiene tutto leggibile:
I tre filtri sono in serie: la policy gira per prima (è quella più economica, una ricerca in tabella + un'eventuale query SQLite); se passa, il vaglio applica giudizio LLM contestuale; se anche il vaglio passa, la sandbox avvolge 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. Le voci canoniche sono definite in runtime/policy.py:CAPABILITY_REGISTRY. 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 predefinita: 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 |
|---|---|---|---|---|
compute:pure | no | none | none | calcolo deterministico in memoria senza I/O esterno |
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 lista bianca (es. gestore di pacchetti) |
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 |
index:read | no | per_target | exact | lettura di un indice locale amministrato da Metnos |
metnos:read | no | per_target | exact | lettura di una risorsa locale amministrata da Metnos |
system:read | no | per_target | exact | lettura diagnostica di sistema su host o dispositivo autorizzato |
provider:access | no | per_target | exact | accesso a rete e credenziali di un provider 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 è una risorsa di sicurezza, non una superficie di estensione liberamente aperta.
provider:access usa come bersaglio esatto il binding della skill. Una
clausola when nel manifest può restringerla a un backend della singola
invocazione; la policy non ricava mai questo permesso dal nome dell'executor o da
un valore libero del piano.
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.
Cinque capability hanno default_approval = none: compute:pure, llm:local, channel:in, time:read, parse:local. Sono azioni locali senza effetti laterali sul mondo esterno. 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 capability registrate. Per ogni cella un esito: allowed, approval_required, denied. È generata da runtime/policy.py:_TABLE e _init_table secondo regole canoniche, non scritte a mano.
| capability | ReadOnly | Supervised | Full |
|---|---|---|---|
compute:pure | allowed | allowed | allowed |
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 |
index:read | approval | approval | allowed |
metnos:read | approval | approval | allowed |
system:read | approval | approval | allowed |
provider:access | 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 o accesso con default_approval = per_target (fs:read, mail:read, calendar:read, index:read, metnos:read, system:read, provider:access) 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.
«Reversibile» qui non è una parola d'ordine: è una garanzia che il runtime deve mantenere, ed è ciò che permette alla policy qui sopra di togliere l'attrito sul reversibile. Ogni executor mutante dichiara un reverse_pattern preso da un catalogo piccolo e chiuso (runtime/reverse_patterns.py), e il runtime conserva per turno lo stato che serve a rigiocarlo. Un solo undo_last_turn ripercorre a ritroso gli step dell'ultimo turno e applica ciascun pattern, riportando un undone_count onesto invece di dichiarare un rollback che non ha eseguito.
Il catalogo è volutamente minuscolo — cinque voci, nessun codice di undo per-executor:
swap_src_dst — uno spostamento o rinomina si annulla scambiando sorgente e destinazione (path del filesystem e cartelle IMAP allo stesso modo, le seconde agganciate allo stabile Message-ID).delete_created_dirs / delete_created_paths — una creazione si annulla rimuovendo ciò che è stato creato, solo finché è ancora vuoto/intatto.restore_blob_backup — prima di ogni scrittura distruttiva il runtime salva una copia byte della versione precedente in $METNOS_HISTORY_DIR/<turn>/blob/<sha256>.bin; l'undo la ripristina. È la parte «intercetta la modifica e tieni una copia della versione precedente», fatta una volta e in modo deterministico.delete_<object>_by_id — una create su un backend (un evento di calendario, un contatto) si annulla cancellandola tramite l'id che la create ha restituito.Due regole tengono tutto onesto. Primo, uno spostamento non è mai una cancellazione implicita: ogni move è COPY → verifica la copia → solo allora DELETE, cosí un move fallito a metà non può perdere dati. Secondo, il reverse pattern è dichiarato nel manifest e scelto dal catalogo chiuso, mai scritto a mano per executor — cosí «questo si può annullare?» si decide a design-time, non si spera a runtime.
Per questo il gate di approvazione non avvolge tutto ciò che modifica lo stato. Sta solo sull'insieme ristretto che il motore di undo davvero non può riprendersi — la mail spedita, il comando shell che ha già cancellato file. Tutto ciò che il catalogo copre gira non presidiato, perché annullarlo è un singolo passo deterministico e non una scommessa.
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 a file singolo: 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: ricerca esatta 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. La portata 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 approvazione (vedi approval_ux): quando una richiesta in attesa si risolve come approvata con ambito «questo e simili», 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 avvolge il comando; il parametro autonomy è già trasmesso tale e quale (cap. 6 di sandbox.html).
Il pianificatore non legge mai direttamente la tabella o i grants: fa una sola chiamata a effective_outcome e si dirama sull'esito. Questo mantiene policy come unico punto di verità sulle regole; il giorno in cui le regole cambieranno (fasce di costo, limiti di frequenza), il pianificatore non si tocca.
Il modulo è eseguibile come script (runtime/policy.py:360-423): utile per ispezione manuale e per costruire cruscotti in poche righe. Quattro sottocomandi.
| comando | cosa fa |
|---|---|
python3 -m policy registry | stampa una riga JSON per ogni capability con tutti i suoi attributi. |
python3 -m policy table | stampa tre righe JSON, una per livello (ReadOnly/Supervised/Full), con l'esito per ogni 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 ciclo completo dei grants (scrittura e rilettura) e l'invariante di sicurezza che separa denied da grants alzabili.
| # | caso | cosa verifica |
|---|---|---|
| 1 | registry_contiene_capability_canoniche | CAPABILITY_REGISTRY espone le 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 limite di frequenza nel codice. La tabella autonomia × capability non distingue ancora "una chiamata vs dieci chiamate al minuto". Una capability concessa è concessa senza tetto; gli abusi accidentali (cicli fuori controllo, executor che chiama HTTP cento volte) non vengono frenati a livello di policy. | Con un secchiello a gettoni (token bucket) per_capability salvato nello stesso DB dei grants. Il meccanismo richiede di scegliere bene i parametri (capacità, ritmo di ricarica) per ogni capability; serve telemetria d'uso reale prima di fissarli. |
Niente fasce di costo. llm:online richiede un approval_required generico: non distingue una chiamata economica da una di fascia alta (un modello di frontiera, facoltativo, può costare un ordine di grandezza in più). Roberto vede tutte le chiamate online allo stesso modo. |
Quando il giudice LLM avrà misurato il fattore costo nella valutazione del vaglio. La policy potrà allora esporre soglie esplicite (es. "fino a $0.05 allowed in Full, oltre approval_required"). |
| Niente policy personalizzata per profilo utente. La tabella è compilata in codice. Una sovrascrittura per profilo utente (es. Roberto vs un familiare con livelli diversi sulle stesse capability) richiede uno strato di configurazione che non esiste ancora. | Quando il pairing supporterà profili multipli con sender_id distinti che mappano a tabelle diverse. Si introdurrà uno strato TOML letto all'avvio (tipo policy_overrides.toml) ma con vincoli di integrità verso il registro. |
Niente revoca automatica. 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 l'insieme di grants attivi diventerà abbastanza grande da rendere utile la pulizia. Implementazione semplice: un processo interno che, all'avvio, 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 chiuso e una tabella generata) più una lettura/scrittura SQLite per i grants. Niente DSL, niente regole espresse in linguaggio naturale, niente configurazione a runtime 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.