Microprogettazione — allineata al codice. Cluster policy 10/10 verde. Aggiunte: due capability nuove, auth.password_storage per login_session e crawl.recursive per find_urls(mode='research'/'archive'), con tier-policy a tre livelli (default / trusted / owned) governata da ~/.config/metnos/owned_domains.json e ~/.config/metnos/trusted_origins.json. Riferimento: runtime/policy.py.
Stato nella sequenza dei microdesign: under approvalapprovedtestedimplemented.
← Indice documentazione Microprogettazione › policy

Metnos

policy — il filtro di legalità di ogni azione
Microprogettazione
Pubblico: chi vuole capire come Metnos decide se un'azione è lecita, sotto approvazione o vietata.

Lettura: 10 minuti.

Indice

  1. Cos'è policy
  2. Capability Registry
  3. Tabella autonomy × capability
  4. Grants per_target persistenti
  5. Esito combinato (effective_outcome)
  6. Integrazione runtime
  7. CLI
  8. Test
  9. Limiti e cosa è rimandato a +

1. Cos'è policy

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

capability richiesta dall'executor livello autonomy ReadOnly/Supervised/Full grant per_target persistenti effective_outcome consenti / chiedi / nega
Figura 1 — Il registro delle capability: la capability richiesta incrocia il livello di autonomy e gli eventuali grant per-bersaglio, producendo l'esito effettivo.

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.

Nessuna decisione politica nel codice degli executor. Un executor non sa nulla del livello di autonomia corrente né dei grants attivi. Si limita a dichiarare le proprie capability nel manifest. La policy è il modulo dove tutte le regole vivono, leggibili in un punto solo, modificabili senza toccare un solo executor.

2. Capability Registry

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:

namecriticaldefault_approvaltarget_kinddescrizione
fs:readnoper_targetpath_globlettura file dal filesystem locale entro path_glob dichiarati
fs:writeper_targetpath_globscrittura/modifica file entro path_glob (critica)
code:execalwaysexactesecuzione di un comando shell di una whitelist (es. pkg manager)
network:httpnoper_targethostHTTP/HTTPS GET/POST verso host autorizzati
llm:localnononenonechiamata LLM locale (llama-server, llama.cpp), costo zero
llm:onlinenoper_targetnonechiamata LLM online (Anthropic, OpenAI,...), costo > 0
mail:readnoper_targetexactlettura messaggi IMAP da una mailbox autorizzata
mail:sendalwaysexactinvio SMTP a destinatari (irreversibile, alta posta in gioco)
channel:innononeexactricezione messaggi da un canale (Telegram, CLI, voice)
channel:outnoper_targetexactinvio messaggi a un canale specifico
time:readnononenonelettura ora corrente e fusi orari
parse:localnononenoneparsing locale di formati noti (PDF, HTML, JSON, CSV)
calendar:readnoper_targetexactlettura 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.

2.1 Lettura del registro

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.

3. Tabella autonomy × capability

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.

capabilityReadOnlySupervisedFull
fs:readapprovalapprovalallowed
fs:writedeniedapprovalallowed
code:execdeniedapprovalapproval
network:httpdeniedapprovalallowed
llm:localallowedallowedallowed
llm:onlinedeniedapprovalallowed
mail:readapprovalapprovalallowed
mail:senddeniedapprovalapproval
channel:inallowedallowedallowed
channel:outdeniedapprovalallowed
time:readallowedallowedallowed
parse:localallowedallowedallowed
calendar:readapprovalapprovalallowed

3.1 Le tre regole che generano la tabella

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.

Full non significa carta bianca. È il livello in cui si rinuncia all'attrito sul reversibile, non sulle azioni che bruciano il ponte. Una mail spedita non si richiama; un comando shell che ha cancellato file non si annulla. Per queste due capability la richiesta di approvazione resta in qualunque profilo il sistema possa girare.

4. Grants per_target persistenti

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.

4.1 Schema SQLite

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.

4.2 API

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

4.3 Quando si scrivono i grants

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.

5. Esito combinato (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 dicegrant attivo per (channel, sender, target)?esito
allowedindifferente, non si interroga il DBallowed
deniedindifferente, non si interroga il DBdenied
approval_requiredallowed
approval_requiredno (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.

Garanzia di sicurezza: un grant non può mai elevare un denied a allowed. È il vincolo che separa la concessione tattica (singolo bersaglio, livello sufficiente) dal cambio di livello strutturale (upgrade del pairing). Se un livello dice "questa azione è vietata", nessuna approvazione passata può sbloccarla: serve cambiare il livello stesso, decisione che vive nel pairing e nel suo flusso di firma. Il test effective_outcome_denied_non_e_alzato_da_grant verifica esattamente questo invariante (cap. 8).

5.1 Esempi concreti

Esempio 1 — Supervised, fs:write su file già concesso
Roberto due settimane fa ha approvato fs:write su ~/Documents/fatture-2026/*. L'agente sta per salvare ~/Documents/fatture-2026/04-Acme.pdf.

Tabella per (Supervised, fs:write) → approval_required. Grant attivo per (telegram, roberto, fs:write, ~/Documents/fatture-2026/*) → esito allowed. Nessuna carta di approvazione, salvataggio diretto.
Esempio 2 — ReadOnly, fs:write
Stesso scenario, ma il livello attivo è ReadOnly (es. una sessione delegata più restrittiva).

Tabella per (ReadOnly, fs:write) → 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.
Esempio 3 — Full, mail:send
Livello Full, capability mail:send, destinatario [email protected] a cui Roberto ha mandato dieci mail in passato.

Tabella per (Full, mail:send) → 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.

6. Integrazione runtime

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:

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.

7. CLI

Il modulo è eseguibile come script (runtime/policy.py:360-423): utile per ispezione manuale e per costruire dashboard in poche righe. Quattro sottocomandi.

comandocosa fa
python3 -m policy registrystampa una riga JSON per ognuna delle 13 capability con tutti i suoi attributi.
python3 -m policy tablestampa 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.

8. Test

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.

#casocosa verifica
1registry_contiene_13_capability_canonicheCAPABILITY_REGISTRY espone esattamente le 13 voci attese, ognuna con i quattro attributi obbligatori.
2is_allowed_readonly_blocca_write_e_exec [security]al livello ReadOnly: fs:write, code:exec, mail:send sono denied.
3is_allowed_supervised_richiede_approval_per_criticalal livello Supervised: fs:write è approval_required; llm:local è allowed; mail:send e code:exec sono approval_required.
4is_allowed_full_mantiene_always_per_critical_irreversibili [security]al livello Full: mail:send e code:exec restano approval_required; fs:write diventa allowed.
5record_grant_e_has_grant_round_tripdopo record_grant sulla quaterna (channel, sender, capability, target), has_grant sulla stessa quaterna ritorna True.
6record_grant_capability_sconosciuta_sollevarecord_grant con una capability fuori dal registro solleva ValueError.
7revoke_grant_disattiva_has_grantdopo revoke_grant(id), has_grant sulla stessa quaterna ritorna False.
8effective_outcome_grant_alza_a_allowedse la tabella dice approval_required e un grant attivo esiste, effective_outcome ritorna allowed.
9effective_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.
10list_grants_filtra_per_canale_e_revokedlist_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.

9. Limiti e cosa è rimandato a +

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

Note finali

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.