pairing — legare un canale e un mittente a un livello di autonomiaIl pairing identifica un channel+sender, non una persona fisica. Lo stesso familiare che scrive a Metnos via Telegram e via Signal sono due pairing distinti, indipendenti, ciascuno col proprio livello di autonomia. Cosí nasce, naturalmente, la possibilità di pairare la stessa persona con livelli diversi a seconda del canale (Telegram = Full perché il telefono è sempre con lui; mail = ReadOnly perché potrebbe essere stata letta da qualcun altro).
Cap. 12 dell'Architettura tratta il pairing come ramo del nodo "identità e perimetro": Metnos non sa chi sei in senso anagrafico, sa solo che la coppia (channel, sender_id) è stata associata, una volta, a un livello di autonomia, e lavora su quello.
Tre livelli di autonomia, una sola dimensione (vedi capitolo 4 dell'Architettura per la giustificazione):
ReadOnly — il sender può scrivere, Metnos riconosce ma non esegue. Pensato per familiari occasionali o canali sensibili.Supervised — il sender ottiene un turno completo, ma le azioni con effetti laterali esterni passano sotto controllo (riservato a +; oggi si comporta come Full ai fini di run_turn ma il livello è già persistito).Full — il sender ha autorità piena: Metnos esegue il turno e risponde.
Tutto il modulo è in /opt/metnos/runtime/pairing.py (354 righe). I due tipi pubblici sono una dataclass e un'eccezione:
@dataclass class Pairing: channel: str sender_id: str autonomy_level: str paired_at: str paired_by: str last_seen: str | None = None revoked_at: str | None = None class PairingError(Exception): pass
La Pairing è quel che le query restituiscono e quel che il chiamante ispeziona. Il PairingError incapsula tutti gli errori del modulo (formato codice, firma, scadenza, doppio consumo) cosicché il chiamante possa gestirli con un solo except. Vedi la classe PairingError nel modulo.
Costanti rilevanti:
| Costante | Valore | Significato |
|---|---|---|
VALID_LEVELS | ("ReadOnly", "Supervised", "Full") | Tupla degli unici livelli accettati. Generare un codice con un livello fuori da questa tupla solleva ValueError. |
CODE_PREFIX | "PAIR." | Prefisso fisso del codice: serve a riconoscere a colpo d'occhio (umano o regex) che la stringa è un pairing code, non un altro tipo di token. |
DEFAULT_TTL_S | 300 | Cinque minuti. Tempo di vita del codice fra emissione e consumo. Configurabile per chiamata, ma volutamente breve nel default. |
PROTOCOL_VERSION | 1 | Versione del payload. Il verify rifiuta payload con v diverso. Quando la struttura cambierà (probabile in +) si bumpa qui e si gestisce il branching. |
DEFAULT_DB_PATH | ~/.local/state/metnos/pairings.db | Posizione di default del registry. Override via env METNOS_PAIRINGS_DB (utile per test). |
Il codice è una stringa stampabile, copiabile a mano se serve. La forma:
PAIR.<base64url(payload_json)>.<base64url(signature)>
Il payload è un JSON deterministico (chiavi ordinate, separatori senza spazi) con cinque campi:
{
"v": 1,
"id": "<uuid12>",
"autonomy": "<ReadOnly|Supervised|Full>",
"exp": <epoch unix in secondi>,
"iss": "author"
}
La firma è Ed25519 sui bytes del JSON serializzato, prodotta con la chiave author di ~/.config/metnos/keys/ (vedi sign.py:28-29, 58-60). La verifica scorre tutte le pubbliche trusted in quella stessa directory (list_trusted_publics in sign.py:66-76) e accetta il codice se almeno una verifica passa. Cosí un Metnos più vecchio resta capace di accettare codici firmati da una nuova chiave finché la pubblica nuova viene aggiunta alla directory.
Esempio di codice reale (TTL 5 minuti, livello ReadOnly):
PAIR.eyJhdXRvbm9teSI6IlJlYWRPbmx5IiwiZXhwIjoxNzA5OTk4ODg4LCJpZCI6IjA0YzNhYThiYjFiNCIsImlzcyI6ImF1dGhvciIsInYiOjF9.5xV3M7T...kWQ
Lunghezza tipica: ~180 caratteri. Si copia-incolla, si manda su iMessage o si dice a voce per le otto sillabe finali.
Due tabelle:
CREATE TABLE IF NOT EXISTS pairings ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel TEXT NOT NULL, sender_id TEXT NOT NULL, autonomy_level TEXT NOT NULL, paired_at TEXT NOT NULL, paired_by TEXT NOT NULL, last_seen TEXT, revoked_at TEXT, UNIQUE(channel, sender_id) ); CREATE TABLE IF NOT EXISTS consumed_codes ( code_id TEXT PRIMARY KEY, consumed_at TEXT NOT NULL, channel TEXT NOT NULL, sender_id TEXT NOT NULL );
| Tabella | Ruolo |
|---|---|
pairings | Una riga per ogni coppia (channel, sender_id) mai pairata. Il vincolo UNIQUE garantisce un solo pairing attivo per coppia; il consumo di un nuovo codice fa upsert e azzera revoked_at last_seen è opzionale e viene aggiornato dal daemon a ogni messaggio (audit). revoked_at non vuoto significa pairing inattivo. |
consumed_codes | Tracciamento single-shot. Ogni codice consumato lascia una riga col suo code_id (i 12 hex del payload). Tentativi di riuso falliscono in BEGIN IMMEDIATE Niente cleanup: la tabella cresce solo del numero di pairing che si fanno nella vita dell'istanza, irrisorio. |
Il file di default vive in ~/.local/state/metnos/pairings.db; la directory viene creata al primo accesso Per i test si imposta METNOS_PAIRINGS_DB a un percorso temporaneo, e si ottiene un database isolato per ogni test case.
Tutte le funzioni accettano un db_path opzionale come keyword-only, in modo che il chiamante possa puntare a un registry diverso (utile per test e per ipotesi future di registry-per-tenant). Senza db_path, si usa METNOS_PAIRINGS_DB oppure il default.
| Funzione | Firma | Cosa fa |
|---|---|---|
generate_code | (autonomy_level, *, ttl_seconds=300, issued_by="author") -> str | Costruisce il payload, firma con la chiave indicata da issued_by, restituisce la stringa PAIR.<...>.<...>. Solleva ValueError se il livello è fuori da VALID_LEVELS. |
consume_code | (code, channel, sender_id, *, db_path=None) -> Pairing | Atomico via BEGIN IMMEDIATE : verifica firma, scadenza, deduplica con consumed_codes, fa upsert in pairings. Restituisce il pairing risultante o solleva PairingError con causa specifica. |
get_pairing | (channel, sender_id, *, db_path=None) -> Pairing | None | Restituisce il pairing attivo (revoked_at IS NULL) o None. |
is_paired | (channel, sender_id, *, db_path=None) -> bool | Wrapper booleano di get_pairing. |
get_autonomy | (channel, sender_id, *, db_path=None) -> str | None | Solo il livello, per chi non vuole tutto il dataclass. |
touch_last_seen | (channel, sender_id, *, db_path=None) -> None | Aggiorna last_seen al timestamp ISO corrente. Chiamata dal daemon a ogni turno andato a buon fine. |
list_pairings | (*, include_revoked=False, db_path=None) -> list[Pairing] | Tutte le righe, opzionalmente includendo i revocati. Ordine: paired_at DESC. |
revoke | (channel, sender_id, *, db_path=None) -> bool | Setta revoked_at sul pairing attivo. Restituisce True se ha modificato qualcosa, False se la coppia non esisteva o era già revocata. |
bootstrap_default_chat_id | (channel, sender_id, *, db_path=None) -> Pairing | Auto-pair Full la prima volta che il default_chat_id scrive, solo se non esistono altri pairing per quel canale. Solleva PairingError altrimenti. |
consume_code. Il vincolo importante è che lo stesso codice non possa essere usato due volte, neanche da due thread o due processi che lo ricevono per coincidenza nello stesso istante. La transazione SQLite con BEGIN IMMEDIATE serializza qualunque tentativo concorrente: il primo vince, il secondo trova la riga in consumed_codes e fallisce con "codice gia' consumato".
Il modulo è eseguibile direttamente. Comandi:
python3 -m pairing generate <ReadOnly|Supervised|Full> [ttl=5m] python3 -m pairing consume <code> <channel> <sender_id> python3 -m pairing list [--include-revoked] python3 -m pairing revoke <channel> <sender_id>
Il TTL accetta tre suffissi : 60s, 5m, 1h. Senza suffisso si interpreta come secondi.
# 1. Roberto genera un codice ReadOnly da 10 minuti
$ python3 -m pairing generate ReadOnly 10m
PAIR.eyJhdXRvbm9teSI6IlJlYWRPbmx5IiwiZXhwIjox...
# 2. lo passa via iMessage al familiare
# 3. il familiare scrive su Telegram: /pair PAIR.eyJ...
# il daemon consuma e risponde "Pairato come ReadOnly. Benvenuto."
# 4. Roberto verifica
$ python3 -m pairing list
{"channel": "telegram", "sender_id": "12345678", "autonomy_level": "ReadOnly",...}
{"channel": "telegram", "sender_id": "99887766", "autonomy_level": "Full",...}
# 5. una settimana dopo, revoca
$ python3 -m pairing revoke telegram 12345678
revoked
Il daemon vive in /opt/metnos/runtime/channels/daemon.py. La funzione handle_message è il punto in cui pairing e runtime si incontrano. La sequenza di decisioni:
/pair (vedi costante PAIR_COMMAND), il daemon va in _handle_pair_command: consuma il codice, risponde "Pairato come <livello>. Benvenuto." in caso di successo, "Pairing fallito: <motivo>" altrimenti. Nessun turno di runtime parte qui, anche se il pairing ha avuto successo: l'utente deve scrivere un secondo messaggio per attivare Metnos.pairing.get_pairing(channel.name, sender_id). Se ritorna None, il daemon prova il bootstrap (vedi sotto). Se anche il bootstrap fallisce, risponde con UNPAIRED_REPLY e termina il turno.touch_last_seen per audit.LEVEL_BLOCKS_RUN = {"ReadOnly"}: risposta cortese (LEVEL_REPLY_BLOCKED), niente run_turn.run_turn(msg.text), si formatta il risultato e si risponde sul canale.
La funzione _try_bootstrap gestisce il caso "Roberto installa Metnos sul proprio nuovo telefono e si scrive": senza un pairing precedente, il primo messaggio del default_chat_id (preso da credentials.env via il TelegramChannel) viene auto-pairato come Full. Tre condizioni devono essere tutte vere:
bootstrap_default_sender del daemon è True (default; si disabilita con --no-bootstrap);sender_id del messaggio coincide col default_chat_id dichiarato dal canale;bootstrap_default_chat_id rifiuta altrimenti).Cosí il bootstrap è sicuro: dopo che esiste un solo pairing sul canale, ulteriori chiamate falliscono e il bootstrap perde efficacia. Roberto stesso, dopo essersi pairato la prima volta, non può più usare il bootstrap per pairare nessun altro: i familiari devono ricevere un codice esplicito.
run_turn
Se run_turn solleva, il daemon non muore: cattura l'eccezione, logga lo stack trace, risponde al sender col messaggio "(errore interno: <tipo>: <testo>)" e prosegue il loop col messaggio successivo Il pairing non viene toccato: lo stesso utente al messaggio successivo è ancora pairato, ancora autorizzato a richiedere altri turni.
Cinque garanzie, ciascuna esistente per una minaccia concreta:
| Garanzia | Da chi protegge |
|---|---|
Firma Ed25519 sul payload garantisce che il codice provenga davvero dalla chiave author di Roberto. | Da un attaccante che scriva al daemon mandando codici inventati. Anche se lo scopre un familiare con buone intenzioni, non può auto-elevarsi: per generare un codice servirebbe la chiave privata, che vive solo sul server di Roberto. |
TTL breve di default (5 minuti, DEFAULT_TTL_S) . | Da un codice che cade in mani sbagliate dopo l'emissione (mail intercettata, screenshot inviato per errore). Cinque minuti sono sufficienti per un consumo legittimo, troppo pochi per un riuso opportunistico ore o giorni dopo. |
Single-shot via tabella consumed_codes . | Da uno stesso codice intercettato e usato da più sender. Il primo che riesce a inviarlo "vince": gli altri ricevono "codice gia' consumato". |
Revoke immediato e check revoked_at IS NULL in get_pairing. | Da un dispositivo perso o un familiare con cui i rapporti si sono rotti. La revoca ha effetto al primo messaggio successivo: nessuna sessione persistente da invalidare separatamente. |
| Niente broadcast del codice: Metnos non lo manda mai sul canale stesso del pairing, sta a Roberto trasportarlo out-of-band (iMessage, voce, foglietto di carta). | Dal canale stesso, se compromesso. Un'eventuale intercettazione del canale Telegram non scopre i codici prodotti. |
Manca volutamente: rate-limit sui tentativi /pair falliti (un attaccante potrebbe spammare codici inventati cercando collisioni, ma con uno spazio di firma Ed25519 il successo è cosmologicamente improbabile e ogni tentativo lascia un log). Vedi limiti.
Cluster pairing: 9 case, tutti verdi. Coprono i flussi nominali e le rotture esplicite del modello.
| # | Caso | Cosa verifica |
|---|---|---|
| 1 | round-trip generate + consume | Codice creato con generate_code("Full", 5m), consumato con consume_code(code, "telegram", "u1"), restituisce un Pairing valido con i campi attesi. |
| 2 | codice scaduto fallisce | TTL = -1 secondo: consume_code solleva PairingError("codice scaduto"). |
| 3 | double consume fallisce | Lo stesso codice consumato la seconda volta solleva PairingError("codice gia' consumato"). |
| 4 | codice manomesso fallisce | Sostituendo un byte nel payload (e ri-base64), la firma non verifica e si solleva PairingError("firma codice non verificata"). |
| 5 | formato invalido fallisce | Stringhe arbitrarie (no prefisso, troppi punti, base64 rotto) sollevano PairingError con messaggi diversi. |
| 6 | revoke rende is_paired falso | Dopo revoke, is_paired sulla stessa coppia ritorna False; il record persiste ma con revoked_at valorizzato. |
| 7 | bootstrap solo se canale vuoto | Prima chiamata: pairing Full creato. Seconda chiamata sullo stesso canale (anche con sender_id diverso): PairingError("bootstrap rifiutato"). |
| 8 | list_pairings filtra revocati | Default include_revoked=False: solo le righe con revoked_at IS NULL. Con True: tutte. |
| 9 | livello invalido in generate | generate_code("Admin",...) solleva ValueError prima ancora di firmare. |
Modulo channels (daemon): 9/9 con un sotto-cluster di 5 case che esercita esplicitamente il flow di pairing fra daemon e modulo:
UNPAIRED_REPLY, niente run_turn;/pair <codice valido> → risposta "Pairato come <livello>. Benvenuto.", pairing scritto in DB, niente run_turn;/pair <codice scaduto/manomesso> → risposta "Pairing fallito: <causa>", nessun pairing scritto;run_turn; last_seen non viene toccato (l'aggiornamento parte solo dopo il check del livello).
Tutti i test girano isolati con un proprio METNOS_PAIRINGS_DB in /tmp, e con una keypair di test creata ad hoc, cosí non si toccano né il registry né le chiavi di sviluppo dell'autore.
Cinque limiti dichiarati, alcuni di disegno volontari, altri rimandati a +:
| Limite | Quando si toglie |
|---|---|
Registry SQLite single-process. Il file può essere aperto da più processi grazie ai lock di SQLite, ma non c'è tuning particolare; il modello operativo presuppone un singolo metnos-server. | Quando ci sarà più di un processo che scrive (es. daemon Telegram + daemon mail in parallelo): si attiva esplicitamente WAL e si riconsiderano i timeout di lock. |
Niente notifica push al pair generato. Roberto genera il codice via CLI; chi riceve il codice scopre l'esito solo quando manda /pair. Il "pair pendente" non è visibile sul daemon. | Quando esisterà una UI di amministrazione (mail di stato, comando /admin dal canale stesso). Per ora la list via CLI è sufficiente. |
| Niente upgrade in place del livello. Per alzare un familiare da ReadOnly a Full bisogna generare un nuovo codice e farlo consumare di nuovo (l'upsert sostituisce il livello). | Quando un comando promote della CLI o un workflow specifico lo richiederà. Oggi l'overhead è minimo (un comando in più) e il vantaggio di avere un solo punto di ingresso (la consume con codice firmato) è maggiore. |
| Niente expire automatico dei pairing. Una volta consumato il codice, il pairing vive finché non viene revocato a mano. | Quando ci sarà un'esigenza concreta (familiare temporaneo, ospite per il weekend): si potrebbe aggiungere un expires_at al pairing stesso (oggi solo il codice ha exp; il pairing che ne nasce è perpetuo). |
Audit minimo. paired_by contiene il nome della chiave che ha firmato il codice (oggi sempre "author"); non c'è un registro di chi ha generato i codici, né un log strutturato dei fallimenti di consume_code (vivono solo nei log del daemon). | Quando esisterà più di una chiave trusted (es. una chiave per dispositivo) e diventerà utile distinguere chi firma cosa. Si introdurranno tabelle code_audit e consume_audit. |
Il modulo è volutamente piccolo (354 righe in un file unico, una sola dipendenza esterna oltre alla standard library: cryptography via sign.py). Non c'è un layer di astrazione "store" interscambiabile, non c'è un protocol formale del codice diverso da JSON+base64+firma. Le scelte sono guidate dal principio di semplicità (vedi memoria feedback_simplicity_first): la complessità va aggiunta quando il problema la chiede, non prima.
Il pairing è il primo pezzo del nodo "perimetro" che diventa eseguibile: tutto quel che si appoggia all'identificazione del sender (rate-limit per livello, scope di executor permessi per livello, audit di chi ha avviato cosa) si poggia su questa primitiva.
users.db e /start
Dal Metnos affianca a pairings.db
un secondo registro users.db in
~/.local/share/metnos/: due tabelle (users +
user_channels) che mappano utenti logici (host
e guest) a uno o più canali. Distinzione di responsabilità:
pairing.py mappa (channel, sender_id) → autonomy + actor_string — canale-specifico, low-level.users.py mappa persone logiche → uno o più canali. Sa di chi e' «Lucia» indipendentemente dal canale, e supporta resolve_recipients per send_messages(to_user="lucia").
Bootstrap automatico: al primo users.list_users
init_db crea l'host con name=$USER,
role='host', autonomy_level='full'. Single-host
policy applicativa: un secondo host viene rifiutato.
autobind_host_telegram(default_chat_id) e' chiamato dal
daemon Telegram al primo poll che riconosce il default_chat_id
di config.
/start <token>Per i guest (familiari, ospiti) il flusso e' user-friendly e prescinde da chiavi Ed25519:
/admin/users nel browser e crea un user name=lucia, role guest, autonomy restricted, owner Roberto.user_channels.pairing_token./start a1b2c3.... Il daemon chiama users.consume_pairing_token('telegram', chat_id, token): se valido, sincronizza il pairing in pairings.db (autonomy='Supervised' per guest, actor=name) e risponde «Sei stato pairato come lucia (guest). Benvenuto/a in Metnos.».
Da quel momento send_messages(messages=[{"to_user":"lucia",...}])
risolve automaticamente il chat_id e invia. Il token e'
azzerato dopo l'uso, un secondo /start con lo stesso token
fallisce (ValueError).
agent_runtime.py::_render_users_known_block itera
users.list_users e inietta una sezione UTENTI NOTI nel
PLANNER prompt subito dopo PROJECT PATHS. Pattern equivalente a PROJECT
PATHS: dato deterministico nel prompt, modifica =
restart del runtime.
Per il pannello /admin dell'http_api
(porta 8770) il login e' separato dal pairing: una sola admin key
(file ~/.config/metnos/admin.key, mode 0600, 256-bit hex
generato al primo boot). Due modalità:
POST /admin/login con la admin key in chiaro → risposta set-cookie HttpOnly SameSite=Strict TTL 7 giorni.Authorization: Bearer <admin.key> a ogni chiamata.
La admin key e' anche la master della cifratura credenziali:
chi controlla il file decifra
~/.config/metnos/credentials/<domain>.json.age.
Backup offline raccomandato. Nei log compare solo il fingerprint
sha256[:16].