Files
microdao-daarion/services/matrix-bridge-dagi/app/control.py
Apple fe6e3d30ae feat(matrix-bridge-dagi): add operator allowlist for control commands (M3.0)
New: app/control.py
  - ControlConfig: operator_allowlist + control_rooms (frozensets)
  - parse_control_config(): validates @user:server + !room:server formats, fail-fast
  - parse_command(): parses !verb subcommand [args] [key=value] up to 512 chars
  - check_authorization(): AND(is_control_room, is_operator) → (bool, reason)
  - Reply helpers: not_implemented, unknown_command, unauthorized, help
  - KNOWN_VERBS: runbook, status, help (M3.1+ stubs)
  - MAX_CMD_LEN=512, MAX_CMD_TOKENS=20

ingress.py:
  - _try_control(): dispatch for control rooms (authorized → audit + reply, unauthorized → audit + optional )
  - join control rooms on startup
  - _enqueue_from_sync: control rooms processed first, never forwarded to agents
  - on_control_command(sender, verb, subcommand) metric callback
  - CONTROL_UNAUTHORIZED_BEHAVIOR: "ignore" | "reply_error"

Audit events:
  matrix.control.command       — authorised command (verb, subcommand, args, kwargs)
  matrix.control.unauthorized  — rejected by allowlist (reason: not_operator | not_control_room)
  matrix.control.unknown_cmd   — authorised but unrecognised verb

Config + main:
  - bridge_operator_allowlist, bridge_control_rooms, control_unauthorized_behavior
  - matrix_bridge_control_commands_total{sender,verb,subcommand} counter
  - /health: control_channel section (enabled, rooms_count, operators_count, behavior)
  - /bridge/mappings: control_rooms + control_operators_count
  - docker-compose: BRIDGE_OPERATOR_ALLOWLIST, BRIDGE_CONTROL_ROOMS, CONTROL_UNAUTHORIZED_BEHAVIOR

Tests: 40 new → 148 total pass
Made-with: Cursor
2026-03-05 01:50:04 -08:00

264 lines
9.3 KiB
Python

"""
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 <path> [node=NODA1]` — Start a runbook run\n"
"`!runbook next <run_id>` — Advance to next step\n"
"`!runbook complete <run_id> step=<n> status=ok` — Mark step complete\n"
"`!runbook evidence <run_id>` — Get evidence artifact path\n"
"`!runbook status <run_id>` — Show current run state\n"
"`!status` — Bridge health summary\n"
"`!help` — This message\n\n"
"_Only authorised operators can issue control commands._"
)