pairing — come riconoscere chi parlaAlcuni canali (Telegram, Signal, Matrix) sono per definizione multi-utente: chiunque conosca l'handle può scrivere. Senza un meccanismo di riconoscimento, myclaw tratterebbe ugualmente Roberto e uno sconosciuto. Il pairing è il rito di "mi presento, aspetto il tuo permesso, poi siamo in affari".
Sequenza in 5 passi. Vedi anche Architettura intro §7 fig. 4 per il diagramma narrativo.
"ciao, posso parlare?" su Telegram.sender="telegram:@nuovo_tizio". Pairing table non lo conosce.K7-DELTA-19 (formato umano, 3 sezioni). Stato pending. Risponde allo sconosciuto: "non ti conosco. Codice: K7-DELTA-19. Il tuo admin lo approverà o negherà."myclaw pairing approve telegram K7-DELTA-19 --as ospite. Pairing firmato, scritto in DB. Il sender ora può interagire, con autonomy mappata al ruolo (§4).
Formato: 3 segmenti di 2-6 caratteri alfanumerici separati da trattini,
ispirato ai pairing code Bluetooth/Apple. Esempi: K7-DELTA-19, RB-AMBER-42.
def generate_pairing_code() -> str:
# segment 1: 2 char (letter+digit)
# segment 2: parola da lista curata 200 parole italiane/inglesi pronunciabili
# segment 3: 2 digit
import secrets
s1 = secrets.choice(string.ascii_uppercase) + secrets.choice(string.digits)
s2 = secrets.choice(WORD_LIST)
s3 = f"{secrets.randbelow(100):02d}"
return f"{s1}-{s2}-{s3}"
Spazio delle chiavi: 26·10 · 200 · 100 ≈ 5.2M. Entropia ~22 bit:
non crittografica, ma sufficiente per contesto casalingo dove la
collisione non è vettore di attacco serio (l'admin approva manualmente; il
codice vive max 1h).
| Ruolo | Autonomy default | Chi è tipicamente |
|---|---|---|
| admin | Supervised (promuovibile a Full via session) | Roberto stesso su un nuovo device. Solo uno o due admin. |
| famiglia | Supervised con batching più generoso | Moglie, figli maggiorenni. Stesse capability operative, memorie separate. |
| ospite | ReadOnly | Amico che chiede per curiosità, utente occasionale. |
| revoked | nessuna — ogni richiesta 403 | Stato post-revoca. Record conservato per audit. |
myclaw pairing approve <channel> <code> --as <role>
myclaw pairing deny <channel> <code> [--never]
myclaw pairing list
myclaw pairing revoke <channel> <sender_id> [--reason]
Il comando è disponibile solo da canale cli:roberto o dal sender
admin stesso. Non è esposto come comando Telegram globale
(troppo facile impersonation).
Una richiesta di pairing attende l'admin. Se l'admin dorme, lo sconosciuto non può stare in limbo all'infinito.
| Timing | Comportamento |
|---|---|
| T + 0 min | Sconosciuto riceve "codice generato, admin notificato". |
| T + 30 min | Se admin non ha risposto: sconosciuto riceve update "admin non risponde, riproverò tra 30 min, poi la richiesta scade". |
| T + 60 min | Admin non risponde: pairing request scade. Sconosciuto riceve "richiesta scaduta, puoi riprovare più tardi". Stato passa da pending a expired. |
| T + 7 giorni | Se lo stesso handle riprova più di 5 volte in 7 giorni senza mai essere approvato: auto-ban per 30 giorni (anti-spam). |
Al momento dell'approvazione, il record di pairing è firmato con una chiave
interna di myclaw (in config/secrets.env come MYCLAW_HMAC_KEY).
La firma copre: channel + sender_id + role + approved_at.
signature = hmac_sha256(
key=MYCLAW_HMAC_KEY,
msg=f"{channel}|{sender_id}|{role}|{approved_at_iso}"
)
A ogni messaggio ingerito, il gateway verifica la firma del pairing record.
Se il record è stato manomesso (improbabile ma possibile se qualcuno edita il
DB a mano), la firma non torna e il sender rientra in unknown.
myclaw pairing revoke <channel> <sender>. Record passa a revoked, mantiene la storia.-- workspace/state/pairings.db
CREATE TABLE pairings (
pairing_id TEXT PRIMARY KEY,
channel TEXT NOT NULL,
sender_id TEXT NOT NULL,
display_name TEXT, -- "@nuovo_tizio" a scopo umano
role TEXT NOT NULL, -- admin | famiglia | ospite | revoked
approved_by TEXT NOT NULL,
approved_at DATETIME NOT NULL,
expires_at DATETIME, -- NULL = no expiry
signature BLOB NOT NULL,
UNIQUE(channel, sender_id)
);
CREATE TABLE pairing_requests (
code TEXT PRIMARY KEY,
channel TEXT NOT NULL,
sender_id TEXT NOT NULL,
requested_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
status TEXT NOT NULL, -- pending | approved | denied | expired
resolved_at DATETIME,
resolved_by TEXT
);
CREATE INDEX idx_pairings_sender ON pairings(channel, sender_id);
CREATE INDEX idx_requests_status ON pairing_requests(status, expires_at);
from typing import Protocol, Literal
from dataclasses import dataclass
@dataclass
class Pairing:
pairing_id: str
channel: str
sender_id: str
display_name: str | None
role: Literal["admin", "famiglia", "ospite", "revoked"]
approved_by: str
approved_at: datetime
expires_at: datetime | None
signature: bytes
@dataclass
class PairingRequest:
code: str
channel: str
sender_id: str
requested_at: datetime
expires_at: datetime
status: Literal["pending", "approved", "denied", "expired"]
class PairingService(Protocol):
async def resolve_sender(
self, channel: str, sender_id: str,
) -> Pairing | None:
"""Dato un sender, ritorna il pairing attivo o None."""
...
async def initiate(
self, channel: str, sender_id: str, display_name: str,
) -> PairingRequest:
"""Sender sconosciuto: genera codice, notifica admin."""
...
async def approve(
self, code: str, role: str, approved_by: str,
) -> Pairing: ...
async def deny(self, code: str, reason: str) -> None: ...
async def revoke(
self, channel: str, sender_id: str, reason: str,
) -> None: ...
async def autonomy_for_role(self, role: str) -> str:
"""Ritorna autonomy level default per ruolo."""
...
async def expire_stale(self) -> int:
"""Job periodico: marca come expired le richieste scadute."""
...
| Alternativa | Perché scartata |
|---|---|
| Codice 6-cifre numerico stile SMS | Meno resistente a typo quando Roberto legge al volo. Le parole aiutano. |
| QR code via Telegram | Non funziona se l'admin approva da CLI senza fotocamera. Formato testuale è universale. |
| Self-service pairing con secret condiviso | Lo sconosciuto dovrebbe avere già accesso a un secret. Perde il senso: chi ha il secret è Roberto. |
| Pairing automatico per handle in whitelist pre-caricata | Utile in futuro per "famiglia", non day-1. Overhead: una whitelist è un'altra cosa da mantenere. |
| Niente ruoli, tutti Supervised | Ospiti non devono avere accesso write. I 4 ruoli sono il minimo funzionante. |
| Invariante | Test |
|---|---|
| Sender unknown → pairing request | Primo messaggio da telegram:@x mai visto → PairingRequest creata, status=pending, codice generato, notifica admin. |
| Nessuna azione finché non approvato | Sender in pending invia "elimina file X" → tool dispatch bloccato, outcome policy_denied con reason="sender not paired". |
| Codice valido attiva pairing | approve telegram K7-DELTA-19 --as ospite → Pairing inserito, sender ora risolvibile con role=ospite. |
| Codice inesistente rifiutato | approve telegram XX-BOGUS-99 → errore, nessun pairing. |
| Scadenza 1h | Request creata a T, non risolta, verifica a T+61min → status=expired, sender notificato. |
| Firma verificata | Edit manuale della tabella pairings (cambia role) → hmac mismatch → sender torna unknown, warning critical nel log. |
| Revoca istantanea | revoke telegram:@x → messaggio successivo da @x → rifiutato. |
| Ospite scade dopo 7gg | Pairing role=ospite creato 7gg+1h fa → resolve_sender ritorna None (scaduto). |
| Auto-ban anti-spam | 5 richieste da stesso sender in 7gg senza mai approvazione → 6a richiesta rifiutata automaticamente per 30gg. |
| Audit log di ogni transizione | Ogni approve/deny/revoke/expire → riga in .audit/pairings.jsonl. |
| Riferimento | Cosa abbiamo preso |
|---|---|
| zeroclaw DM pairing | Il pattern generale: codice out-of-band, approvazione esplicita. |
| Bluetooth pairing UX | Codice human-readable a segmenti. |
| Architettura intro §7 (figura 4) | Diagramma sequenza narrativo. |
| Policy §2 (autonomy level) | Il ruolo pairing mappa a autonomy. |
| Observability §3 | Audit log dedicato pairings.jsonl. |
myclaw — pairing v1.0 — 2026-04-22
Primo doc di fase 3. Prossimo: constitution.html.