""" confirm_store — M9.0: Two-step confirmation for dangerous control commands. Flow: 1. Operator issues a dangerous command (e.g. !node set, !policy import mode=replace). 2. Bridge calls ConfirmStore.add(..., callback=) → returns a nonce. 3. Bridge replies: "Type !confirm within Ns to apply." 4. Operator sends !confirm . 5. Bridge calls ConfirmStore.pop(nonce, sender_hash) → returns PendingConfirmation. 6. Bridge executes callback() → (reply_text, diff_summary). 7. Audit trail: matrix.control.intent / matrix.control.confirmed / matrix.control.applied. Safety: - One pending entry per sender (new request replaces old). - Nonce is sender-bound: wrong sender_hash → pop returns None. - TTL enforced via monotonic time; expired entries not returned. - Nonce: 6 uppercase alphanumeric (NONCE_LEN chars from NONCE_CHARS). """ from __future__ import annotations import secrets import string import threading import time from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple NONCE_LEN = 6 NONCE_CHARS = string.ascii_uppercase + string.digits _DEFAULT_TTL_S = 120.0 def make_nonce() -> str: """Generate a cryptographically random 6-char uppercase alphanumeric nonce.""" return "".join(secrets.choice(NONCE_CHARS) for _ in range(NONCE_LEN)) @dataclass class PendingConfirmation: """A pending two-step confirmation waiting for !confirm .""" nonce: str sender_hash: str verb: str # e.g. "node.set", "room.agents set", "policy.import" normalized_args: str # human-readable args for audit action_summary: str # "!node set room=!x:s node=NODA2" room_id: str # Matrix room_id where the intent was issued callback: Callable[[], Awaitable[Tuple[str, str]]] # async () → (reply_text, diff_summary) expires_at: float # time.monotonic() deadline class ConfirmStore: """ In-memory, thread-safe store for pending two-step confirmation entries. One pending entry per sender at a time. If the same sender issues a new dangerous command before confirming the previous one, the old entry is replaced (new nonce issued). """ def __init__(self, ttl_s: float = _DEFAULT_TTL_S) -> None: self.ttl_s = ttl_s self._lock = threading.RLock() self._by_nonce: Dict[str, PendingConfirmation] = {} self._by_sender: Dict[str, str] = {} # sender_hash → nonce # ── Public API ──────────────────────────────────────────────────────────── def add( self, sender_hash: str, verb: str, normalized_args: str, action_summary: str, room_id: str, callback: Callable[[], Awaitable[Tuple[str, str]]], ) -> str: """ Create a pending confirmation entry. Returns the nonce string. If the sender already has a pending entry it is replaced (old nonce becomes invalid immediately). """ nonce = make_nonce() expires_at = time.monotonic() + self.ttl_s entry = PendingConfirmation( nonce=nonce, sender_hash=sender_hash, verb=verb, normalized_args=normalized_args, action_summary=action_summary, room_id=room_id, callback=callback, expires_at=expires_at, ) with self._lock: # Evict any previous pending entry for this sender old_nonce = self._by_sender.get(sender_hash) if old_nonce: self._by_nonce.pop(old_nonce, None) self._by_nonce[nonce] = entry self._by_sender[sender_hash] = nonce return nonce def pop(self, nonce: str, sender_hash: str) -> Optional[PendingConfirmation]: """ Retrieve and atomically remove a pending confirmation. Returns None if: - nonce does not exist, - sender_hash does not match the entry owner, - or the entry has expired. """ nonce = nonce.upper() with self._lock: entry = self._by_nonce.get(nonce) if entry is None: return None if entry.sender_hash != sender_hash: # Wrong sender — deny without disclosing any detail return None if time.monotonic() > entry.expires_at: # Expired — clean up and deny self._by_nonce.pop(nonce, None) self._by_sender.pop(entry.sender_hash, None) return None # Valid confirmation — consume the entry self._by_nonce.pop(nonce) self._by_sender.pop(sender_hash, None) return entry def pending_nonce(self, sender_hash: str) -> Optional[str]: """ Return the current pending nonce for a sender (non-destructive peek). Returns None if no entry or the entry has expired. """ with self._lock: nonce = self._by_sender.get(sender_hash) if nonce is None: return None entry = self._by_nonce.get(nonce) if entry is None or time.monotonic() > entry.expires_at: # Lazy eviction self._by_nonce.pop(nonce, None) self._by_sender.pop(sender_hash, None) return None return nonce def pending_count(self) -> int: """Number of non-expired pending entries (for /health, metrics).""" now = time.monotonic() with self._lock: return sum(1 for e in self._by_nonce.values() if now <= e.expires_at) def cleanup(self) -> int: """Eagerly remove all expired entries. Returns count removed.""" now = time.monotonic() removed = 0 with self._lock: expired_nonces = [ n for n, e in self._by_nonce.items() if now > e.expires_at ] for n in expired_nonces: entry = self._by_nonce.pop(n) self._by_sender.pop(entry.sender_hash, None) removed += 1 return removed