""" Matrix Bridge — Control Command Layer (M3.0) Handles operator commands from designated control rooms. Access policy (AND): 1. Message arrives in a BRIDGE_CONTROL_ROOM 2. Sender is in BRIDGE_OPERATOR_ALLOWLIST 3. Message starts with "!" prefix (e.g. "!runbook start ...") Design principles: - Bridge is a TRANSPORT only — it never executes scripts directly. - All actions go via sofiia-console internal API (M3.1+). - Every command attempt is audited regardless of authorization. - Unknown commands acknowledged but not executed (forward-compatible). Audit events emitted: matrix.control.command — authorised command recognised matrix.control.unauthorized — command from non-operator or wrong room matrix.control.unknown_cmd — authorised but unrecognised verb """ import logging import re from dataclasses import dataclass, field from typing import Dict, FrozenSet, List, Optional, Tuple logger = logging.getLogger(__name__) # ── Constants ───────────────────────────────────────────────────────────────── # Supported control verbs (M3.1+ will implement them fully) VERB_RUNBOOK = "runbook" VERB_STATUS = "status" VERB_HELP = "help" KNOWN_VERBS: FrozenSet[str] = frozenset({VERB_RUNBOOK, VERB_STATUS, VERB_HELP}) # Max command line length to guard against garbage injection _MAX_CMD_LEN = 512 # Max number of tokens in a single command _MAX_CMD_TOKENS = 20 # Matrix user ID format: @localpart:server _MATRIX_USER_RE = re.compile(r"^@[A-Za-z0-9._\-/=+]+:[A-Za-z0-9.\-]+$") # Room ID format: !localpart:server _ROOM_ID_RE = re.compile(r"^![A-Za-z0-9\-_.]+:[A-Za-z0-9\-_.]+$") # ── Data structures ──────────────────────────────────────────────────────────── @dataclass(frozen=True) class ControlCommand: """Parsed control command from a Matrix message.""" verb: str # e.g. "runbook" subcommand: str # e.g. "start", "next", "complete", "evidence", "status" args: Tuple[str, ...] # remaining positional args kwargs: Dict[str, str] # key=value pairs parsed from args (e.g. node=NODA1) raw: str # original message text is_known: bool # True if verb in KNOWN_VERBS @classmethod def from_tokens(cls, tokens: List[str], raw: str) -> "ControlCommand": """Build ControlCommand from pre-split tokens (first token must not include '!').""" verb = tokens[0].lower() if tokens else "" subcommand = tokens[1].lower() if len(tokens) > 1 else "" remaining = tokens[2:] if len(tokens) > 2 else [] positional: List[str] = [] kw: Dict[str, str] = {} for token in remaining: if "=" in token: k, _, v = token.partition("=") kw[k.lower().strip()] = v.strip() else: positional.append(token) return cls( verb=verb, subcommand=subcommand, args=tuple(positional), kwargs=kw, raw=raw, is_known=verb in KNOWN_VERBS, ) @dataclass class ControlConfig: """ Parsed operator access policy for the control channel. operator_allowlist: Frozenset of Matrix user IDs allowed to issue commands. control_rooms: Frozenset of room IDs designated as control channels. """ operator_allowlist: FrozenSet[str] = field(default_factory=frozenset) control_rooms: FrozenSet[str] = field(default_factory=frozenset) @property def is_enabled(self) -> bool: """Control channel is effective only when both sets are non-empty.""" return bool(self.operator_allowlist and self.control_rooms) # ── Parsers ──────────────────────────────────────────────────────────────────── def parse_control_config( raw_allowlist: str, raw_control_rooms: str, ) -> ControlConfig: """ Parse BRIDGE_OPERATOR_ALLOWLIST and BRIDGE_CONTROL_ROOMS. Allowlist format: "@ivan:daarion.space,@sergiy:daarion.space" Control rooms fmt: "!opsroom:server,!opsroom2:server2" Raises ValueError on: - Malformed Matrix user ID - Malformed room ID """ operators: List[str] = [] errors: List[str] = [] for entry in raw_allowlist.split(","): uid = entry.strip() if not uid: continue if not _MATRIX_USER_RE.match(uid): errors.append(f"Invalid operator user_id: {uid!r}") else: operators.append(uid) rooms: List[str] = [] for entry in raw_control_rooms.split(","): rid = entry.strip() if not rid: continue if not _ROOM_ID_RE.match(rid): errors.append(f"Invalid control room_id: {rid!r}") else: rooms.append(rid) if errors: raise ValueError(f"Control config parse errors: {'; '.join(errors)}") cfg = ControlConfig( operator_allowlist=frozenset(operators), control_rooms=frozenset(rooms), ) if cfg.is_enabled: logger.info( "Control channel enabled: %d operators, %d rooms", len(operators), len(rooms), ) else: logger.info("Control channel disabled (empty allowlist or no control rooms)") return cfg # ── Message inspection ──────────────────────────────────────────────────────── def is_control_message(text: str) -> bool: """Returns True if message looks like a control command (starts with '!').""" return bool(text and text.strip().startswith("!")) def is_control_room(room_id: str, config: ControlConfig) -> bool: return room_id in config.control_rooms def is_operator(sender: str, config: ControlConfig) -> bool: return sender in config.operator_allowlist def parse_command(text: str) -> Optional[ControlCommand]: """ Parse a control message into a ControlCommand. Returns None if text is not a control command or is malformed/too long. """ stripped = text.strip() if not stripped.startswith("!"): return None if len(stripped) > _MAX_CMD_LEN: logger.warning("Control command too long (%d chars) — rejected", len(stripped)) return None # Strip leading '!' body = stripped[1:] tokens = body.split() if not tokens: return None if len(tokens) > _MAX_CMD_TOKENS: logger.warning("Control command has too many tokens (%d) — rejected", len(tokens)) return None return ControlCommand.from_tokens(tokens, raw=stripped) # ── Authorization check ─────────────────────────────────────────────────────── def check_authorization( sender: str, room_id: str, config: ControlConfig, ) -> Tuple[bool, str]: """ Returns (authorized: bool, rejection_reason: str). Reasons: - "not_operator": sender not in allowlist - "not_control_room": room not in control_rooms - "ok": authorized """ if not is_control_room(room_id, config): return False, "not_control_room" if not is_operator(sender, config): logger.warning( "Unauthorized control attempt: sender=%s room=%s not in allowlist", sender, room_id, ) return False, "not_operator" return True, "ok" # ── Reply helpers ───────────────────────────────────────────────────────────── def not_implemented_reply(cmd: ControlCommand) -> str: """Reply for known commands not yet implemented (M3.0 stub).""" return ( f"✅ Command acknowledged: `{cmd.raw}`\n" f"⏳ `!{cmd.verb} {cmd.subcommand}` — implementation pending (M3.1+)." ) def unknown_command_reply(cmd: ControlCommand) -> str: """Reply for unrecognised verbs.""" return ( f"⚠️ Unknown command: `{cmd.raw}`\n" f"Known verbs: {', '.join(sorted(KNOWN_VERBS))}.\n" f"Type `!help` for usage." ) def unauthorized_reply(reason: str) -> str: """Reply for unauthorized command attempts (sent only when behavior=reply_error).""" if reason == "not_operator": return "⛔ Not authorised: your Matrix ID is not in the operator allowlist." return "⛔ Not authorised: this room is not a control channel." def help_reply() -> str: """Brief help text.""" return ( "**DAGI Bridge — Control Commands**\n\n" "`!runbook start [node=NODA1]` — Start a runbook run\n" "`!runbook next ` — Advance to next step\n" "`!runbook complete step= status=ok` — Mark step complete\n" "`!runbook evidence ` — Get evidence artifact path\n" "`!runbook status ` — Show current run state\n" "`!status` — Bridge health summary\n" "`!help` — This message\n\n" "_Only authorised operators can issue control commands._" )